@dotsetlabs/dotclaw 1.1.0 → 1.3.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 (58) hide show
  1. package/README.md +1 -0
  2. package/config-examples/runtime.json +19 -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-job-classifier.d.ts +16 -0
  18. package/dist/background-job-classifier.d.ts.map +1 -0
  19. package/dist/background-job-classifier.js +124 -0
  20. package/dist/background-job-classifier.js.map +1 -0
  21. package/dist/background-jobs.d.ts +47 -0
  22. package/dist/background-jobs.d.ts.map +1 -0
  23. package/dist/background-jobs.js +406 -0
  24. package/dist/background-jobs.js.map +1 -0
  25. package/dist/container-protocol.d.ts +3 -0
  26. package/dist/container-protocol.d.ts.map +1 -1
  27. package/dist/container-runner.d.ts +1 -0
  28. package/dist/container-runner.d.ts.map +1 -1
  29. package/dist/container-runner.js +2 -2
  30. package/dist/container-runner.js.map +1 -1
  31. package/dist/dashboard.d.ts +1 -0
  32. package/dist/dashboard.d.ts.map +1 -1
  33. package/dist/dashboard.js +8 -0
  34. package/dist/dashboard.js.map +1 -1
  35. package/dist/db.d.ts +39 -1
  36. package/dist/db.d.ts.map +1 -1
  37. package/dist/db.js +198 -0
  38. package/dist/db.js.map +1 -1
  39. package/dist/index.js +257 -224
  40. package/dist/index.js.map +1 -1
  41. package/dist/metrics.d.ts +1 -0
  42. package/dist/metrics.d.ts.map +1 -1
  43. package/dist/metrics.js +9 -0
  44. package/dist/metrics.js.map +1 -1
  45. package/dist/runtime-config.d.ts +22 -4
  46. package/dist/runtime-config.d.ts.map +1 -1
  47. package/dist/runtime-config.js +29 -5
  48. package/dist/runtime-config.js.map +1 -1
  49. package/dist/task-scheduler.d.ts +5 -0
  50. package/dist/task-scheduler.d.ts.map +1 -1
  51. package/dist/task-scheduler.js +121 -2
  52. package/dist/task-scheduler.js.map +1 -1
  53. package/dist/tool-policy.d.ts.map +1 -1
  54. package/dist/tool-policy.js +6 -0
  55. package/dist/tool-policy.js.map +1 -1
  56. package/dist/types.d.ts +41 -0
  57. package/dist/types.d.ts.map +1 -1
  58. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -6,8 +6,9 @@ import path from 'path';
6
6
  import { DATA_DIR, MAIN_GROUP_FOLDER, GROUPS_DIR, IPC_POLL_INTERVAL, TIMEZONE, CONTAINER_MODE, WARM_START_ENABLED, ENV_PATH } from './config.js';
7
7
  // Load .env from the canonical location (~/.dotclaw/.env)
8
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';
9
+ import { initDatabase, storeMessage, upsertChat, getMessagesSinceCursor, getChatState, updateChatState, createTask, updateTask, deleteTask, getTaskById, getAllGroupSessions, setGroupSession, deleteGroupSession, pauseTasksForGroup, linkMessageToTrace, getTraceIdForMessage, recordUserFeedback } from './db.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';
@@ -24,6 +25,7 @@ import { createTraceBase, executeAgentRun, recordAgentTelemetry, AgentExecutionE
24
25
  import { logger } from './logger.js';
25
26
  import { startDashboard, setTelegramConnected, setLastMessageTime, setMessageQueueDepth } from './dashboard.js';
26
27
  import { humanizeError } from './error-messages.js';
28
+ import { classifyBackgroundJob } from './background-job-classifier.js';
27
29
  const runtime = loadRuntimeConfig();
28
30
  function buildTriggerRegex(pattern) {
29
31
  if (!pattern)
@@ -35,10 +37,6 @@ function buildTriggerRegex(pattern) {
35
37
  return null;
36
38
  }
37
39
  }
38
- function isPreemptedError(err) {
39
- const message = err instanceof Error ? err.message : String(err);
40
- return message.toLowerCase().includes('preempt');
41
- }
42
40
  function buildAvailableGroupsSnapshot() {
43
41
  return Object.entries(registeredGroups).map(([jid, info]) => ({
44
42
  jid,
@@ -158,22 +156,14 @@ const PROGRESS_MESSAGES = runtime.host.progress.messages.length > 0
158
156
  const HEARTBEAT_ENABLED = runtime.host.heartbeat.enabled;
159
157
  const HEARTBEAT_INTERVAL_MS = runtime.host.heartbeat.intervalMs;
160
158
  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
- }
159
+ const BACKGROUND_JOBS_ENABLED = runtime.host.backgroundJobs.enabled;
160
+ const AUTO_SPAWN_CONFIG = runtime.host.backgroundJobs.autoSpawn;
161
+ const AUTO_SPAWN_ENABLED = BACKGROUND_JOBS_ENABLED && AUTO_SPAWN_CONFIG.enabled;
162
+ const AUTO_SPAWN_FOREGROUND_TIMEOUT_MS = AUTO_SPAWN_CONFIG.foregroundTimeoutMs;
163
+ const AUTO_SPAWN_ON_TIMEOUT = AUTO_SPAWN_CONFIG.onTimeout;
164
+ const AUTO_SPAWN_ON_TOOL_LIMIT = AUTO_SPAWN_CONFIG.onToolLimit;
165
+ const AUTO_SPAWN_CLASSIFIER_ENABLED = AUTO_SPAWN_CONFIG.classifier.enabled;
166
+ const TOOL_CALL_FALLBACK_PATTERN = /tool calls? but did not get a final response/i;
177
167
  // Initialize Telegram bot with extended timeout for long-running agent tasks
178
168
  const telegrafBot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN, {
179
169
  handlerTimeout: TELEGRAM_HANDLER_TIMEOUT_MS
@@ -186,7 +176,6 @@ let registeredGroups = {};
186
176
  const TELEGRAM_MAX_MESSAGE_LENGTH = 4000;
187
177
  const TELEGRAM_SEND_DELAY_MS = 250;
188
178
  const messageQueues = new Map();
189
- const inFlightRuns = new Map();
190
179
  const draftSessions = new Map();
191
180
  function parseTelegramStreamMode(value) {
192
181
  const normalized = value.trim().toLowerCase();
@@ -577,13 +566,6 @@ async function finalizeStreamedMessage(msg, draftId, text) {
577
566
  clearDraftSession(msg.chatId, draftId);
578
567
  }
579
568
  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
569
  const existing = messageQueues.get(msg.chatId);
588
570
  if (existing) {
589
571
  existing.pendingMessage = msg;
@@ -659,18 +641,6 @@ async function processMessage(msg) {
659
641
  ${lines.join('\n')}
660
642
  </messages>`;
661
643
  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
644
  const traceBase = createTraceBase({
675
645
  chatId: msg.chatId,
676
646
  groupFolder: group.folder,
@@ -687,6 +657,69 @@ ${lines.join('\n')}
687
657
  let output = null;
688
658
  let context = null;
689
659
  let errorMessage = null;
660
+ const isTimeoutError = (value) => {
661
+ if (!value)
662
+ return false;
663
+ return /timed out|timeout/i.test(value);
664
+ };
665
+ const maybeAutoSpawn = async (reason, detail) => {
666
+ if (!AUTO_SPAWN_ENABLED)
667
+ return false;
668
+ if (reason === 'timeout' && !AUTO_SPAWN_ON_TIMEOUT)
669
+ return false;
670
+ if (reason === 'tool_limit' && !AUTO_SPAWN_ON_TOOL_LIMIT)
671
+ return false;
672
+ const result = spawnBackgroundJob({
673
+ prompt,
674
+ groupFolder: group.folder,
675
+ chatJid: msg.chatId,
676
+ contextMode: 'group',
677
+ tags: ['auto-spawn', reason],
678
+ parentTraceId: traceBase.trace_id,
679
+ parentMessageId: msg.messageId
680
+ });
681
+ if (!result.ok || !result.jobId) {
682
+ logger.warn({ chatId: msg.chatId, reason, error: result.error }, 'Auto-spawn background job failed');
683
+ return false;
684
+ }
685
+ const detailLine = detail ? `\n\nReason: ${detail}` : '';
686
+ await sendMessage(msg.chatId, `Queued this as background job ${result.jobId}. I'll report back when it's done. You can keep chatting while it runs.${detailLine}`, { messageThreadId: msg.messageThreadId });
687
+ if (lastMessage) {
688
+ updateChatState(msg.chatId, lastMessage.timestamp, lastMessage.id);
689
+ }
690
+ if (draftId) {
691
+ clearDraftSession(msg.chatId, draftId);
692
+ }
693
+ return true;
694
+ };
695
+ if (AUTO_SPAWN_ENABLED && AUTO_SPAWN_CLASSIFIER_ENABLED && lastMessage) {
696
+ try {
697
+ const classifierResult = await classifyBackgroundJob({
698
+ lastMessage,
699
+ recentMessages: missedMessages,
700
+ isGroup: msg.isGroup,
701
+ chatType: msg.chatType
702
+ });
703
+ logger.info({
704
+ chatId: msg.chatId,
705
+ decision: classifierResult.shouldBackground,
706
+ confidence: classifierResult.confidence,
707
+ latencyMs: classifierResult.latencyMs,
708
+ model: classifierResult.model,
709
+ reason: classifierResult.reason,
710
+ error: classifierResult.error
711
+ }, 'Background job classifier decision');
712
+ if (classifierResult.shouldBackground) {
713
+ const autoSpawned = await maybeAutoSpawn('classifier');
714
+ if (autoSpawned) {
715
+ return true;
716
+ }
717
+ }
718
+ }
719
+ catch (err) {
720
+ logger.warn({ chatId: msg.chatId, err }, 'Background job classifier failed');
721
+ }
722
+ }
690
723
  const progressNotifier = createProgressNotifier({
691
724
  enabled: PROGRESS_ENABLED && !streamingEnabled,
692
725
  initialDelayMs: PROGRESS_INITIAL_MS,
@@ -717,7 +750,10 @@ ${lines.join('\n')}
717
750
  minChars: TELEGRAM_STREAM_MIN_CHARS
718
751
  }
719
752
  : undefined,
720
- availableGroups: buildAvailableGroupsSnapshot()
753
+ availableGroups: buildAvailableGroupsSnapshot(),
754
+ timeoutMs: AUTO_SPAWN_ENABLED && AUTO_SPAWN_FOREGROUND_TIMEOUT_MS > 0
755
+ ? AUTO_SPAWN_FOREGROUND_TIMEOUT_MS
756
+ : undefined
721
757
  });
722
758
  output = execution.output;
723
759
  context = execution.context;
@@ -768,6 +804,12 @@ ${lines.join('\n')}
768
804
  source: traceBase.source
769
805
  });
770
806
  }
807
+ if (isTimeoutError(message)) {
808
+ const autoSpawned = await maybeAutoSpawn('timeout', message);
809
+ if (autoSpawned) {
810
+ return true;
811
+ }
812
+ }
771
813
  const userMessage = humanizeError(errorMessage || 'Unknown error');
772
814
  await sendMessage(msg.chatId, userMessage, { messageThreadId: msg.messageThreadId });
773
815
  if (draftId) {
@@ -788,7 +830,14 @@ ${lines.join('\n')}
788
830
  });
789
831
  }
790
832
  logger.error({ group: group.name, error: output.error }, 'Container agent error');
791
- const userMessage = humanizeError(errorMessage || output.error || 'Unknown error');
833
+ const errorText = errorMessage || output.error || 'Unknown error';
834
+ if (isTimeoutError(errorText)) {
835
+ const autoSpawned = await maybeAutoSpawn('timeout', errorText);
836
+ if (autoSpawned) {
837
+ return true;
838
+ }
839
+ }
840
+ const userMessage = humanizeError(errorText);
792
841
  await sendMessage(msg.chatId, userMessage, { messageThreadId: msg.messageThreadId });
793
842
  if (draftId) {
794
843
  clearDraftSession(msg.chatId, draftId);
@@ -819,6 +868,22 @@ ${lines.join('\n')}
819
868
  }
820
869
  }
821
870
  else if (output.tool_calls && output.tool_calls.length > 0) {
871
+ const toolLimitHit = !output.result || !output.result.trim() || TOOL_CALL_FALLBACK_PATTERN.test(output.result);
872
+ if (toolLimitHit) {
873
+ const autoSpawned = await maybeAutoSpawn('tool_limit', 'Tool-call step limit reached');
874
+ if (autoSpawned) {
875
+ if (context) {
876
+ recordAgentTelemetry({
877
+ traceBase,
878
+ output,
879
+ context,
880
+ metricsSource: 'telegram',
881
+ toolAuditSource: 'message'
882
+ });
883
+ }
884
+ return true;
885
+ }
886
+ }
822
887
  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
888
  if (draftId) {
824
889
  clearDraftSession(msg.chatId, draftId);
@@ -841,186 +906,6 @@ ${lines.join('\n')}
841
906
  }
842
907
  return true;
843
908
  }
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
909
  function startIpcWatcher() {
1025
910
  const ipcBaseDir = path.join(DATA_DIR, 'ipc');
1026
911
  fs.mkdirSync(ipcBaseDir, { recursive: true });
@@ -1597,6 +1482,139 @@ async function processRequestIpc(data, sourceGroup, isMain) {
1597
1482
  const groups = listRegisteredGroups();
1598
1483
  return { id: requestId, ok: true, result: { groups } };
1599
1484
  }
1485
+ case 'run_task': {
1486
+ const taskId = typeof payload.task_id === 'string' ? payload.task_id : '';
1487
+ if (!taskId) {
1488
+ return { id: requestId, ok: false, error: 'task_id is required.' };
1489
+ }
1490
+ const task = getTaskById(taskId);
1491
+ if (!task) {
1492
+ return { id: requestId, ok: false, error: 'Task not found.' };
1493
+ }
1494
+ if (!isMain && task.group_folder !== sourceGroup) {
1495
+ return { id: requestId, ok: false, error: 'Unauthorized task run attempt.' };
1496
+ }
1497
+ const result = await runTaskNow(taskId, {
1498
+ sendMessage: async (jid, text) => { await sendMessage(jid, text); },
1499
+ registeredGroups: () => registeredGroups,
1500
+ getSessions: () => sessions,
1501
+ setSession: (groupFolder, sessionId) => { sessions[groupFolder] = sessionId; }
1502
+ });
1503
+ return {
1504
+ id: requestId,
1505
+ ok: result.ok,
1506
+ result: { result: result.result ?? null },
1507
+ error: result.ok ? undefined : result.error
1508
+ };
1509
+ }
1510
+ case 'spawn_job': {
1511
+ const prompt = typeof payload.prompt === 'string' ? payload.prompt.trim() : '';
1512
+ if (!prompt) {
1513
+ return { id: requestId, ok: false, error: 'prompt is required.' };
1514
+ }
1515
+ const targetGroup = (typeof payload.target_group === 'string' && isMain)
1516
+ ? payload.target_group
1517
+ : sourceGroup;
1518
+ const groupEntry = Object.entries(registeredGroups).find(([, group]) => group.folder === targetGroup);
1519
+ if (!groupEntry) {
1520
+ return { id: requestId, ok: false, error: 'Target group not registered.' };
1521
+ }
1522
+ const [chatId, group] = groupEntry;
1523
+ const result = spawnBackgroundJob({
1524
+ prompt,
1525
+ groupFolder: group.folder,
1526
+ chatJid: chatId,
1527
+ contextMode: (payload.context_mode === 'group' || payload.context_mode === 'isolated')
1528
+ ? payload.context_mode
1529
+ : undefined,
1530
+ timeoutMs: typeof payload.timeout_ms === 'number' ? payload.timeout_ms : undefined,
1531
+ maxToolSteps: typeof payload.max_tool_steps === 'number' ? payload.max_tool_steps : undefined,
1532
+ toolAllow: Array.isArray(payload.tool_allow) ? payload.tool_allow : undefined,
1533
+ toolDeny: Array.isArray(payload.tool_deny) ? payload.tool_deny : undefined,
1534
+ modelOverride: typeof payload.model_override === 'string' ? payload.model_override : undefined,
1535
+ priority: typeof payload.priority === 'number' ? payload.priority : undefined,
1536
+ tags: Array.isArray(payload.tags) ? payload.tags : undefined,
1537
+ parentTraceId: typeof payload.parent_trace_id === 'string' ? payload.parent_trace_id : undefined,
1538
+ parentMessageId: typeof payload.parent_message_id === 'string' ? payload.parent_message_id : undefined
1539
+ });
1540
+ return {
1541
+ id: requestId,
1542
+ ok: result.ok,
1543
+ result: result.ok ? { job_id: result.jobId } : undefined,
1544
+ error: result.ok ? undefined : result.error
1545
+ };
1546
+ }
1547
+ case 'job_status': {
1548
+ const jobId = typeof payload.job_id === 'string' ? payload.job_id : '';
1549
+ if (!jobId) {
1550
+ return { id: requestId, ok: false, error: 'job_id is required.' };
1551
+ }
1552
+ const job = getBackgroundJobStatus(jobId);
1553
+ if (!job) {
1554
+ return { id: requestId, ok: false, error: 'Job not found.' };
1555
+ }
1556
+ if (!isMain && job.group_folder !== sourceGroup) {
1557
+ return { id: requestId, ok: false, error: 'Unauthorized job status request.' };
1558
+ }
1559
+ return { id: requestId, ok: true, result: { job } };
1560
+ }
1561
+ case 'list_jobs': {
1562
+ const targetGroup = (typeof payload.target_group === 'string' && isMain)
1563
+ ? payload.target_group
1564
+ : sourceGroup;
1565
+ const statusRaw = typeof payload.status === 'string' ? payload.status : undefined;
1566
+ const allowedStatuses = ['queued', 'running', 'succeeded', 'failed', 'canceled', 'timed_out'];
1567
+ const status = statusRaw && allowedStatuses.includes(statusRaw)
1568
+ ? statusRaw
1569
+ : undefined;
1570
+ const limit = typeof payload.limit === 'number' ? payload.limit : undefined;
1571
+ const jobs = listBackgroundJobsForGroup({ groupFolder: targetGroup, status, limit });
1572
+ return { id: requestId, ok: true, result: { jobs } };
1573
+ }
1574
+ case 'cancel_job': {
1575
+ const jobId = typeof payload.job_id === 'string' ? payload.job_id : '';
1576
+ if (!jobId) {
1577
+ return { id: requestId, ok: false, error: 'job_id is required.' };
1578
+ }
1579
+ const job = getBackgroundJobStatus(jobId);
1580
+ if (!job) {
1581
+ return { id: requestId, ok: false, error: 'Job not found.' };
1582
+ }
1583
+ if (!isMain && job.group_folder !== sourceGroup) {
1584
+ return { id: requestId, ok: false, error: 'Unauthorized job cancel attempt.' };
1585
+ }
1586
+ const result = cancelBackgroundJob(jobId);
1587
+ return { id: requestId, ok: result.ok, error: result.error };
1588
+ }
1589
+ case 'job_update': {
1590
+ const jobId = typeof payload.job_id === 'string' ? payload.job_id : '';
1591
+ const message = typeof payload.message === 'string' ? payload.message.trim() : '';
1592
+ const levelRaw = typeof payload.level === 'string' ? payload.level : 'progress';
1593
+ const allowedLevels = ['info', 'progress', 'warn', 'error'];
1594
+ const level = allowedLevels.includes(levelRaw)
1595
+ ? levelRaw
1596
+ : 'progress';
1597
+ if (!jobId || !message) {
1598
+ return { id: requestId, ok: false, error: 'job_id and message are required.' };
1599
+ }
1600
+ const job = getBackgroundJobStatus(jobId);
1601
+ if (!job) {
1602
+ return { id: requestId, ok: false, error: 'Job not found.' };
1603
+ }
1604
+ if (!isMain && job.group_folder !== sourceGroup) {
1605
+ return { id: requestId, ok: false, error: 'Unauthorized job update attempt.' };
1606
+ }
1607
+ const result = recordBackgroundJobUpdate({
1608
+ jobId,
1609
+ level,
1610
+ message,
1611
+ data: typeof payload.data === 'object' && payload.data ? payload.data : undefined
1612
+ });
1613
+ if (result.ok && payload.notify === true && job.chat_jid) {
1614
+ await sendMessage(job.chat_jid, `Background job ${job.id} update:\n\n${message}`);
1615
+ }
1616
+ return { id: requestId, ok: result.ok, error: result.error };
1617
+ }
1600
1618
  default:
1601
1619
  return { id: requestId, ok: false, error: `Unknown request type: ${data.type}` };
1602
1620
  }
@@ -1903,6 +1921,11 @@ function setupTelegramHandlers() {
1903
1921
  const isPrivate = chatType === 'private';
1904
1922
  const senderId = String(ctx.from?.id || ctx.chat.id);
1905
1923
  const senderName = ctx.from?.first_name || ctx.from?.username || 'User';
1924
+ const chatName = ('title' in ctx.chat && ctx.chat.title)
1925
+ || ('username' in ctx.chat && ctx.chat.username)
1926
+ || ctx.from?.first_name
1927
+ || ctx.from?.username
1928
+ || senderName;
1906
1929
  const content = ctx.message.text;
1907
1930
  const timestamp = new Date(ctx.message.date * 1000).toISOString();
1908
1931
  const messageId = String(ctx.message.message_id);
@@ -1911,6 +1934,7 @@ function setupTelegramHandlers() {
1911
1934
  logger.info({ chatId, isGroup, senderName }, `Telegram message: ${content.substring(0, 50)}...`);
1912
1935
  try {
1913
1936
  // Store message in database
1937
+ upsertChat({ chatId, name: chatName, lastMessageTime: timestamp });
1914
1938
  storeMessage(String(ctx.message.message_id), chatId, senderId, senderName, content, timestamp, false);
1915
1939
  }
1916
1940
  catch (error) {
@@ -2054,6 +2078,15 @@ async function main() {
2054
2078
  setGroupSession(groupFolder, sessionId);
2055
2079
  }
2056
2080
  });
2081
+ startBackgroundJobLoop({
2082
+ sendMessage: sendMessageForScheduler,
2083
+ registeredGroups: () => registeredGroups,
2084
+ getSessions: () => sessions,
2085
+ setSession: (groupFolder, sessionId) => {
2086
+ sessions[groupFolder] = sessionId;
2087
+ setGroupSession(groupFolder, sessionId);
2088
+ }
2089
+ });
2057
2090
  startIpcWatcher();
2058
2091
  startMaintenanceLoop();
2059
2092
  startHeartbeatLoop();