zardbot-telegram 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.env.example +116 -0
  2. package/LICENSE +21 -0
  3. package/README.md +250 -0
  4. package/dist/agent/manager.js +88 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +49 -0
  7. package/dist/bot/commands/abort.js +121 -0
  8. package/dist/bot/commands/commands.js +480 -0
  9. package/dist/bot/commands/definitions.js +27 -0
  10. package/dist/bot/commands/help.js +10 -0
  11. package/dist/bot/commands/models.js +38 -0
  12. package/dist/bot/commands/new.js +70 -0
  13. package/dist/bot/commands/opencode-start.js +101 -0
  14. package/dist/bot/commands/opencode-stop.js +44 -0
  15. package/dist/bot/commands/projects.js +223 -0
  16. package/dist/bot/commands/rename.js +139 -0
  17. package/dist/bot/commands/sessions.js +351 -0
  18. package/dist/bot/commands/start.js +43 -0
  19. package/dist/bot/commands/status.js +95 -0
  20. package/dist/bot/commands/task.js +399 -0
  21. package/dist/bot/commands/tasklist.js +220 -0
  22. package/dist/bot/commands/voice.js +145 -0
  23. package/dist/bot/handlers/agent.js +118 -0
  24. package/dist/bot/handlers/context.js +100 -0
  25. package/dist/bot/handlers/document.js +65 -0
  26. package/dist/bot/handlers/inline-menu.js +119 -0
  27. package/dist/bot/handlers/model.js +143 -0
  28. package/dist/bot/handlers/permission.js +235 -0
  29. package/dist/bot/handlers/prompt.js +240 -0
  30. package/dist/bot/handlers/question.js +390 -0
  31. package/dist/bot/handlers/tts.js +89 -0
  32. package/dist/bot/handlers/variant.js +138 -0
  33. package/dist/bot/handlers/voice.js +173 -0
  34. package/dist/bot/index.js +977 -0
  35. package/dist/bot/message-patterns.js +4 -0
  36. package/dist/bot/middleware/auth.js +30 -0
  37. package/dist/bot/middleware/interaction-guard.js +95 -0
  38. package/dist/bot/middleware/unknown-command.js +22 -0
  39. package/dist/bot/streaming/response-streamer.js +286 -0
  40. package/dist/bot/streaming/tool-call-streamer.js +285 -0
  41. package/dist/bot/utils/busy-guard.js +15 -0
  42. package/dist/bot/utils/commands.js +21 -0
  43. package/dist/bot/utils/file-download.js +91 -0
  44. package/dist/bot/utils/finalize-assistant-response.js +52 -0
  45. package/dist/bot/utils/keyboard.js +69 -0
  46. package/dist/bot/utils/send-with-markdown-fallback.js +165 -0
  47. package/dist/bot/utils/telegram-text.js +28 -0
  48. package/dist/bot/utils/thinking-message.js +8 -0
  49. package/dist/cli/args.js +98 -0
  50. package/dist/cli.js +80 -0
  51. package/dist/config.js +97 -0
  52. package/dist/i18n/de.js +357 -0
  53. package/dist/i18n/en.js +357 -0
  54. package/dist/i18n/es.js +357 -0
  55. package/dist/i18n/fr.js +357 -0
  56. package/dist/i18n/index.js +109 -0
  57. package/dist/i18n/ru.js +357 -0
  58. package/dist/i18n/zh.js +357 -0
  59. package/dist/index.js +26 -0
  60. package/dist/interaction/busy.js +8 -0
  61. package/dist/interaction/cleanup.js +32 -0
  62. package/dist/interaction/guard.js +140 -0
  63. package/dist/interaction/manager.js +106 -0
  64. package/dist/interaction/types.js +1 -0
  65. package/dist/keyboard/manager.js +172 -0
  66. package/dist/keyboard/types.js +1 -0
  67. package/dist/model/capabilities.js +62 -0
  68. package/dist/model/context-limit.js +57 -0
  69. package/dist/model/manager.js +259 -0
  70. package/dist/model/types.js +24 -0
  71. package/dist/opencode/client.js +13 -0
  72. package/dist/opencode/events.js +140 -0
  73. package/dist/permission/manager.js +100 -0
  74. package/dist/permission/types.js +1 -0
  75. package/dist/pinned/format.js +29 -0
  76. package/dist/pinned/manager.js +682 -0
  77. package/dist/pinned/types.js +1 -0
  78. package/dist/process/manager.js +273 -0
  79. package/dist/process/types.js +1 -0
  80. package/dist/project/manager.js +88 -0
  81. package/dist/question/manager.js +176 -0
  82. package/dist/question/types.js +1 -0
  83. package/dist/rename/manager.js +53 -0
  84. package/dist/runtime/bootstrap.js +350 -0
  85. package/dist/runtime/mode.js +74 -0
  86. package/dist/runtime/paths.js +37 -0
  87. package/dist/scheduled-task/creation-manager.js +113 -0
  88. package/dist/scheduled-task/display.js +239 -0
  89. package/dist/scheduled-task/executor.js +87 -0
  90. package/dist/scheduled-task/foreground-state.js +32 -0
  91. package/dist/scheduled-task/next-run.js +207 -0
  92. package/dist/scheduled-task/runtime.js +368 -0
  93. package/dist/scheduled-task/schedule-parser.js +169 -0
  94. package/dist/scheduled-task/store.js +65 -0
  95. package/dist/scheduled-task/types.js +19 -0
  96. package/dist/session/cache-manager.js +455 -0
  97. package/dist/session/manager.js +10 -0
  98. package/dist/settings/manager.js +158 -0
  99. package/dist/stt/client.js +97 -0
  100. package/dist/summary/aggregator.js +1136 -0
  101. package/dist/summary/formatter.js +491 -0
  102. package/dist/summary/subagent-formatter.js +63 -0
  103. package/dist/summary/tool-message-batcher.js +90 -0
  104. package/dist/tts/client.js +130 -0
  105. package/dist/utils/error-format.js +29 -0
  106. package/dist/utils/logger.js +127 -0
  107. package/dist/utils/safe-background-task.js +33 -0
  108. package/dist/utils/telegram-rate-limit-retry.js +93 -0
  109. package/dist/variant/manager.js +103 -0
  110. package/dist/variant/types.js +1 -0
  111. package/package.json +79 -0
@@ -0,0 +1,368 @@
1
+ import { config } from "../config.js";
2
+ import { escapePlainTextForTelegramMarkdownV2, formatSummaryWithMode, } from "../summary/formatter.js";
3
+ import { t } from "../i18n/index.js";
4
+ import { logger } from "../utils/logger.js";
5
+ import { safeBackgroundTask } from "../utils/safe-background-task.js";
6
+ import { sendBotText } from "../bot/utils/telegram-text.js";
7
+ import { executeScheduledTask } from "./executor.js";
8
+ import { foregroundSessionState } from "./foreground-state.js";
9
+ import { computeNextRunAt, isTaskDue } from "./next-run.js";
10
+ import { getScheduledTask, listScheduledTasks, removeScheduledTask, replaceScheduledTasks, updateScheduledTask, } from "./store.js";
11
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
12
+ const TELEGRAM_MESSAGE_LIMIT = 4096;
13
+ const TASK_DESCRIPTION_PREVIEW_LENGTH = 64;
14
+ const RESTART_INTERRUPTED_ERROR = "Interrupted by bot restart during scheduled task execution.";
15
+ function getScheduledTaskDeliveryFormat() {
16
+ return config.bot.messageFormatMode === "markdown" ? "markdown_v2" : "raw";
17
+ }
18
+ function buildScheduledTaskSuccessMessageParts(delivery) {
19
+ if (!delivery.resultText) {
20
+ return [delivery.notificationText];
21
+ }
22
+ if (config.bot.messageFormatMode !== "markdown") {
23
+ return formatSummaryWithMode(`${delivery.notificationText}\n\n${delivery.resultText}`, config.bot.messageFormatMode);
24
+ }
25
+ const header = escapePlainTextForTelegramMarkdownV2(delivery.notificationText);
26
+ const resultParts = formatSummaryWithMode(delivery.resultText, config.bot.messageFormatMode);
27
+ if (resultParts.length === 0) {
28
+ return [header];
29
+ }
30
+ const firstPart = `${header}\n\n${resultParts[0]}`;
31
+ if (firstPart.length <= TELEGRAM_MESSAGE_LIMIT) {
32
+ return [firstPart, ...resultParts.slice(1)];
33
+ }
34
+ return [header, ...resultParts];
35
+ }
36
+ function normalizeTaskPrompt(prompt) {
37
+ const normalized = prompt.replace(/\s+/g, " ").trim();
38
+ if (normalized.length <= TASK_DESCRIPTION_PREVIEW_LENGTH) {
39
+ return normalized;
40
+ }
41
+ return `${normalized.slice(0, TASK_DESCRIPTION_PREVIEW_LENGTH)}...`;
42
+ }
43
+ function buildSuccessDelivery(task, runAt, resultText) {
44
+ return {
45
+ taskId: task.id,
46
+ scheduleSummary: task.scheduleSummary,
47
+ prompt: task.prompt,
48
+ runAt,
49
+ status: "success",
50
+ notificationText: t("task.run.success", {
51
+ description: normalizeTaskPrompt(task.prompt),
52
+ }),
53
+ resultText,
54
+ };
55
+ }
56
+ function buildErrorDelivery(task, runAt, errorMessage) {
57
+ return {
58
+ taskId: task.id,
59
+ scheduleSummary: task.scheduleSummary,
60
+ prompt: task.prompt,
61
+ runAt,
62
+ status: "error",
63
+ notificationText: t("task.run.error", {
64
+ description: normalizeTaskPrompt(task.prompt),
65
+ error: errorMessage,
66
+ }),
67
+ };
68
+ }
69
+ export class ScheduledTaskRuntime {
70
+ botApi = null;
71
+ chatId = null;
72
+ initialized = false;
73
+ timersByTaskId = new Map();
74
+ runningTaskIds = new Set();
75
+ deliveryQueue = [];
76
+ flushInProgress = false;
77
+ async initialize(bot) {
78
+ this.botApi = bot.api;
79
+ this.chatId = config.telegram.allowedUserId;
80
+ if (this.initialized) {
81
+ return;
82
+ }
83
+ this.initialized = true;
84
+ await this.recoverTasksOnStartup();
85
+ await this.flushDeferredDeliveries();
86
+ }
87
+ registerTask(task) {
88
+ if (!this.initialized) {
89
+ return;
90
+ }
91
+ this.scheduleTask(task);
92
+ }
93
+ removeTask(taskId) {
94
+ const timer = this.timersByTaskId.get(taskId);
95
+ if (timer) {
96
+ clearTimeout(timer);
97
+ this.timersByTaskId.delete(taskId);
98
+ }
99
+ this.runningTaskIds.delete(taskId);
100
+ this.deliveryQueue = this.deliveryQueue.filter((delivery) => delivery.taskId !== taskId);
101
+ }
102
+ async flushDeferredDeliveries() {
103
+ if (this.flushInProgress ||
104
+ !this.botApi ||
105
+ this.chatId === null ||
106
+ foregroundSessionState.isBusy() ||
107
+ this.deliveryQueue.length === 0) {
108
+ return;
109
+ }
110
+ this.flushInProgress = true;
111
+ try {
112
+ while (this.deliveryQueue.length > 0 && !foregroundSessionState.isBusy()) {
113
+ const nextDelivery = this.deliveryQueue[0];
114
+ const sent = await this.sendDelivery(nextDelivery);
115
+ if (!sent) {
116
+ break;
117
+ }
118
+ this.deliveryQueue.shift();
119
+ }
120
+ }
121
+ finally {
122
+ this.flushInProgress = false;
123
+ }
124
+ }
125
+ __resetForTests() {
126
+ for (const timer of this.timersByTaskId.values()) {
127
+ clearTimeout(timer);
128
+ }
129
+ this.botApi = null;
130
+ this.chatId = null;
131
+ this.initialized = false;
132
+ this.timersByTaskId.clear();
133
+ this.runningTaskIds.clear();
134
+ this.deliveryQueue = [];
135
+ this.flushInProgress = false;
136
+ }
137
+ async recoverTasksOnStartup() {
138
+ const tasks = listScheduledTasks();
139
+ if (tasks.length === 0) {
140
+ return;
141
+ }
142
+ const now = new Date();
143
+ let hasChanges = false;
144
+ const normalizedTasks = tasks.map((task) => {
145
+ const normalizedTask = { ...task, model: { ...task.model } };
146
+ if (normalizedTask.lastStatus === "running") {
147
+ normalizedTask.lastStatus = "error";
148
+ normalizedTask.lastError = RESTART_INTERRUPTED_ERROR;
149
+ hasChanges = true;
150
+ }
151
+ if (normalizedTask.kind === "cron") {
152
+ if (!normalizedTask.nextRunAt || Number.isNaN(Date.parse(normalizedTask.nextRunAt))) {
153
+ try {
154
+ normalizedTask.nextRunAt = computeNextRunAt(normalizedTask, now);
155
+ }
156
+ catch (error) {
157
+ logger.error(`[ScheduledTaskRuntime] Failed to recover next run for cron task: id=${normalizedTask.id}`, error);
158
+ normalizedTask.nextRunAt = null;
159
+ normalizedTask.lastStatus = "error";
160
+ normalizedTask.lastError =
161
+ normalizedTask.lastError || "Failed to recover cron schedule.";
162
+ }
163
+ hasChanges = true;
164
+ }
165
+ }
166
+ else {
167
+ const runAtMs = Date.parse(normalizedTask.runAt);
168
+ if (Number.isNaN(runAtMs)) {
169
+ normalizedTask.nextRunAt = null;
170
+ normalizedTask.lastStatus = "error";
171
+ normalizedTask.lastError =
172
+ normalizedTask.lastError || "Invalid one-time task runAt value.";
173
+ hasChanges = true;
174
+ }
175
+ else if (normalizedTask.nextRunAt === null && normalizedTask.lastStatus === "idle") {
176
+ normalizedTask.nextRunAt = new Date(runAtMs).toISOString();
177
+ hasChanges = true;
178
+ }
179
+ }
180
+ return normalizedTask;
181
+ });
182
+ if (hasChanges) {
183
+ await replaceScheduledTasks(normalizedTasks);
184
+ }
185
+ for (const task of normalizedTasks) {
186
+ this.scheduleTask(task);
187
+ }
188
+ }
189
+ scheduleTask(task) {
190
+ this.removeTaskTimer(task.id);
191
+ if (!task.nextRunAt) {
192
+ return;
193
+ }
194
+ const nextRunAtMs = Date.parse(task.nextRunAt);
195
+ if (Number.isNaN(nextRunAtMs)) {
196
+ logger.warn(`[ScheduledTaskRuntime] Invalid nextRunAt: id=${task.id}, value=${task.nextRunAt}`);
197
+ return;
198
+ }
199
+ const delayMs = nextRunAtMs - Date.now();
200
+ if (delayMs <= 0) {
201
+ this.startExecution(task.id);
202
+ return;
203
+ }
204
+ const timeoutMs = Math.min(delayMs, MAX_TIMER_DELAY_MS);
205
+ const timer = setTimeout(() => {
206
+ this.timersByTaskId.delete(task.id);
207
+ const currentTask = getScheduledTask(task.id);
208
+ if (!currentTask) {
209
+ return;
210
+ }
211
+ if (isTaskDue(currentTask)) {
212
+ this.startExecution(task.id);
213
+ return;
214
+ }
215
+ this.scheduleTask(currentTask);
216
+ }, timeoutMs);
217
+ this.timersByTaskId.set(task.id, timer);
218
+ }
219
+ removeTaskTimer(taskId) {
220
+ const timer = this.timersByTaskId.get(taskId);
221
+ if (!timer) {
222
+ return;
223
+ }
224
+ clearTimeout(timer);
225
+ this.timersByTaskId.delete(taskId);
226
+ }
227
+ startExecution(taskId) {
228
+ if (this.runningTaskIds.has(taskId)) {
229
+ return;
230
+ }
231
+ const task = getScheduledTask(taskId);
232
+ if (!task) {
233
+ this.removeTask(taskId);
234
+ return;
235
+ }
236
+ if (!isTaskDue(task)) {
237
+ this.scheduleTask(task);
238
+ return;
239
+ }
240
+ this.runningTaskIds.add(taskId);
241
+ safeBackgroundTask({
242
+ taskName: `scheduledTask.run.${taskId}`,
243
+ task: async () => {
244
+ await this.executeTask(taskId);
245
+ },
246
+ onError: (error) => {
247
+ logger.error(`[ScheduledTaskRuntime] Scheduled task run crashed: id=${taskId}`, error);
248
+ this.runningTaskIds.delete(taskId);
249
+ },
250
+ });
251
+ }
252
+ async executeTask(taskId) {
253
+ const taskSnapshot = getScheduledTask(taskId);
254
+ if (!taskSnapshot) {
255
+ this.removeTask(taskId);
256
+ this.runningTaskIds.delete(taskId);
257
+ return;
258
+ }
259
+ const startedAt = new Date().toISOString();
260
+ const runningTask = await updateScheduledTask(taskId, (task) => ({
261
+ ...task,
262
+ lastStatus: "running",
263
+ lastError: null,
264
+ lastRunAt: startedAt,
265
+ runCount: task.runCount + 1,
266
+ }));
267
+ if (!runningTask) {
268
+ this.removeTask(taskId);
269
+ this.runningTaskIds.delete(taskId);
270
+ return;
271
+ }
272
+ try {
273
+ const result = await executeScheduledTask(runningTask);
274
+ if (result.status === "success") {
275
+ await this.handleSuccessfulExecution(runningTask, result.finishedAt, result.resultText || "");
276
+ }
277
+ else {
278
+ await this.handleFailedExecution(runningTask, result.finishedAt, result.errorMessage || "Unknown error");
279
+ }
280
+ }
281
+ finally {
282
+ this.runningTaskIds.delete(taskId);
283
+ }
284
+ }
285
+ async handleSuccessfulExecution(task, finishedAt, resultText) {
286
+ const delivery = buildSuccessDelivery(task, finishedAt, resultText);
287
+ if (task.kind === "once") {
288
+ await removeScheduledTask(task.id);
289
+ this.removeTask(task.id);
290
+ await this.enqueueDelivery(delivery);
291
+ return;
292
+ }
293
+ let nextRunAt;
294
+ try {
295
+ nextRunAt = computeNextRunAt(task, new Date(finishedAt));
296
+ }
297
+ catch (error) {
298
+ logger.error(`[ScheduledTaskRuntime] Failed to compute next run after success: id=${task.id}`, error);
299
+ nextRunAt = null;
300
+ }
301
+ const updatedTask = await updateScheduledTask(task.id, (currentTask) => ({
302
+ ...currentTask,
303
+ lastStatus: "success",
304
+ lastError: null,
305
+ nextRunAt,
306
+ }));
307
+ if (updatedTask) {
308
+ this.scheduleTask(updatedTask);
309
+ }
310
+ await this.enqueueDelivery(delivery);
311
+ }
312
+ async handleFailedExecution(task, finishedAt, errorMessage) {
313
+ const delivery = buildErrorDelivery(task, finishedAt, errorMessage);
314
+ let nextRunAt = null;
315
+ if (task.kind === "cron") {
316
+ try {
317
+ nextRunAt = computeNextRunAt(task, new Date(finishedAt));
318
+ }
319
+ catch (error) {
320
+ logger.error(`[ScheduledTaskRuntime] Failed to compute next run after error: id=${task.id}`, error);
321
+ }
322
+ }
323
+ const updatedTask = await updateScheduledTask(task.id, (currentTask) => ({
324
+ ...currentTask,
325
+ lastStatus: "error",
326
+ lastError: errorMessage,
327
+ nextRunAt,
328
+ }));
329
+ if (updatedTask) {
330
+ this.scheduleTask(updatedTask);
331
+ }
332
+ await this.enqueueDelivery(delivery);
333
+ }
334
+ async enqueueDelivery(delivery) {
335
+ if (this.deliveryQueue.length === 0 &&
336
+ !this.flushInProgress &&
337
+ !foregroundSessionState.isBusy() &&
338
+ (await this.sendDelivery(delivery))) {
339
+ return;
340
+ }
341
+ this.deliveryQueue.push(delivery);
342
+ }
343
+ async sendDelivery(delivery) {
344
+ if (!this.botApi || this.chatId === null) {
345
+ return false;
346
+ }
347
+ try {
348
+ const messageParts = delivery.status === "success"
349
+ ? buildScheduledTaskSuccessMessageParts(delivery)
350
+ : [delivery.notificationText];
351
+ const format = delivery.status === "success" ? getScheduledTaskDeliveryFormat() : "raw";
352
+ for (const part of messageParts) {
353
+ await sendBotText({
354
+ api: this.botApi,
355
+ chatId: this.chatId,
356
+ text: part,
357
+ format,
358
+ });
359
+ }
360
+ return true;
361
+ }
362
+ catch (error) {
363
+ logger.error(`[ScheduledTaskRuntime] Failed to send delivery: id=${delivery.taskId}, status=${delivery.status}`, error);
364
+ return false;
365
+ }
366
+ }
367
+ }
368
+ export const scheduledTaskRuntime = new ScheduledTaskRuntime();
@@ -0,0 +1,169 @@
1
+ import { opencodeClient } from "../opencode/client.js";
2
+ import { logger } from "../utils/logger.js";
3
+ const SCHEDULE_PARSE_SESSION_TITLE = "Scheduled task schedule parser";
4
+ function getLocalTimezone() {
5
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
6
+ }
7
+ function isRecord(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+ function isValidIsoDatetime(value) {
11
+ return typeof value === "string" && !Number.isNaN(Date.parse(value));
12
+ }
13
+ function isValidTimezone(value) {
14
+ if (typeof value !== "string" || !value.trim()) {
15
+ return false;
16
+ }
17
+ try {
18
+ new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ function extractJsonPayload(rawText) {
26
+ const trimmed = rawText.trim();
27
+ if (!trimmed) {
28
+ throw new Error("Empty schedule parser response");
29
+ }
30
+ const directCandidates = [trimmed];
31
+ const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
32
+ if (fencedMatch?.[1]) {
33
+ directCandidates.unshift(fencedMatch[1].trim());
34
+ }
35
+ const firstBrace = trimmed.indexOf("{");
36
+ const lastBrace = trimmed.lastIndexOf("}");
37
+ if (firstBrace >= 0 && lastBrace > firstBrace) {
38
+ directCandidates.push(trimmed.slice(firstBrace, lastBrace + 1));
39
+ }
40
+ for (const candidate of directCandidates) {
41
+ try {
42
+ return JSON.parse(candidate);
43
+ }
44
+ catch {
45
+ // Try next candidate.
46
+ }
47
+ }
48
+ throw new Error("Schedule parser returned invalid JSON");
49
+ }
50
+ function validateParsedSchedule(value) {
51
+ if (!isRecord(value)) {
52
+ throw new Error("Schedule parser returned an invalid payload");
53
+ }
54
+ const kind = value.kind;
55
+ const summary = value.summary;
56
+ const timezone = value.timezone;
57
+ const nextRunAt = value.nextRunAt;
58
+ if (typeof summary !== "string" || !summary.trim()) {
59
+ throw new Error("Schedule summary is missing");
60
+ }
61
+ if (!isValidTimezone(timezone)) {
62
+ throw new Error("Schedule timezone is invalid");
63
+ }
64
+ if (!isValidIsoDatetime(nextRunAt)) {
65
+ throw new Error("Schedule nextRunAt is invalid");
66
+ }
67
+ if (kind === "cron") {
68
+ if (typeof value.cron !== "string" || !value.cron.trim()) {
69
+ throw new Error("Schedule cron expression is missing");
70
+ }
71
+ return {
72
+ kind,
73
+ cron: value.cron,
74
+ timezone,
75
+ summary: summary.trim(),
76
+ nextRunAt,
77
+ };
78
+ }
79
+ if (kind === "once") {
80
+ if (!isValidIsoDatetime(value.runAt)) {
81
+ throw new Error("Schedule runAt is invalid");
82
+ }
83
+ return {
84
+ kind,
85
+ runAt: value.runAt,
86
+ timezone,
87
+ summary: summary.trim(),
88
+ nextRunAt,
89
+ };
90
+ }
91
+ throw new Error("Schedule kind is invalid");
92
+ }
93
+ function parseSchedulePayload(rawText) {
94
+ const payload = extractJsonPayload(rawText);
95
+ if (isRecord(payload) && typeof payload.error === "string" && payload.error.trim()) {
96
+ throw new Error(payload.error.trim());
97
+ }
98
+ return validateParsedSchedule(payload);
99
+ }
100
+ function collectResponseText(parts) {
101
+ return parts
102
+ .filter((part) => part.type === "text" && typeof part.text === "string" && !part.ignored)
103
+ .map((part) => part.text)
104
+ .join("")
105
+ .trim();
106
+ }
107
+ function buildSchedulePrompt(scheduleText, timezone) {
108
+ const now = new Date().toISOString();
109
+ return [
110
+ "Parse the following natural-language task schedule and return JSON only.",
111
+ "Do not use markdown, explanations, code fences, or any extra text.",
112
+ `Assume the default timezone is ${timezone}.`,
113
+ `Current date/time reference: ${now}.`,
114
+ "Supported interpretations include recurring schedules and one-time schedules.",
115
+ "If parsing succeeds, return exactly one JSON object with keys: kind, timezone, summary, nextRunAt, and either cron or runAt.",
116
+ 'Use kind="cron" for recurring schedules and kind="once" for one-time schedules.',
117
+ "summary must be a concise human-readable description in the same language as the input.",
118
+ "nextRunAt and runAt must be ISO 8601 timestamps with timezone offset.",
119
+ 'If parsing fails or input is ambiguous, return {"error":"short explanation"}.',
120
+ "",
121
+ `Input: ${scheduleText}`,
122
+ ].join("\n");
123
+ }
124
+ export async function parseTaskSchedule(scheduleText, directory) {
125
+ const trimmedScheduleText = scheduleText.trim();
126
+ if (!trimmedScheduleText) {
127
+ throw new Error("Schedule text is empty");
128
+ }
129
+ const trimmedDirectory = directory.trim();
130
+ if (!trimmedDirectory) {
131
+ throw new Error("Schedule parser directory is empty");
132
+ }
133
+ const timezone = getLocalTimezone();
134
+ let sessionId = null;
135
+ try {
136
+ const { data: session, error: createError } = await opencodeClient.session.create({
137
+ directory: trimmedDirectory,
138
+ title: SCHEDULE_PARSE_SESSION_TITLE,
139
+ });
140
+ if (createError || !session) {
141
+ throw createError || new Error("Failed to create temporary schedule parser session");
142
+ }
143
+ sessionId = session.id;
144
+ const { data: response, error: promptError } = await opencodeClient.session.prompt({
145
+ sessionID: session.id,
146
+ directory: session.directory,
147
+ system: "You are a schedule parser. Your only job is to convert user schedule text into strict JSON output.",
148
+ parts: [{ type: "text", text: buildSchedulePrompt(trimmedScheduleText, timezone) }],
149
+ });
150
+ if (promptError || !response) {
151
+ throw promptError || new Error("Failed to parse schedule");
152
+ }
153
+ const responseText = collectResponseText(response.parts);
154
+ if (!responseText) {
155
+ throw new Error("Schedule parser returned an empty response");
156
+ }
157
+ return parseSchedulePayload(responseText);
158
+ }
159
+ finally {
160
+ if (sessionId) {
161
+ try {
162
+ await opencodeClient.session.delete({ sessionID: sessionId });
163
+ }
164
+ catch (error) {
165
+ logger.warn(`[ScheduledTaskScheduleParser] Failed to delete temporary session: sessionId=${sessionId}`, error);
166
+ }
167
+ }
168
+ }
169
+ }
@@ -0,0 +1,65 @@
1
+ import { getScheduledTasks, setScheduledTasks } from "../settings/manager.js";
2
+ import { logger } from "../utils/logger.js";
3
+ import { cloneScheduledTask } from "./types.js";
4
+ let scheduledTaskMutationQueue = Promise.resolve();
5
+ async function mutateScheduledTasks(mutator) {
6
+ const runMutation = async () => {
7
+ const currentTasks = listScheduledTasks();
8
+ const { tasks, result } = await mutator(currentTasks);
9
+ await setScheduledTasks(tasks.map((task) => cloneScheduledTask(task)));
10
+ return result;
11
+ };
12
+ const mutationPromise = scheduledTaskMutationQueue.then(runMutation, runMutation);
13
+ scheduledTaskMutationQueue = mutationPromise.catch(() => undefined);
14
+ return mutationPromise;
15
+ }
16
+ export function listScheduledTasks() {
17
+ return getScheduledTasks().map((task) => cloneScheduledTask(task));
18
+ }
19
+ export function getScheduledTask(taskId) {
20
+ const task = listScheduledTasks().find((item) => item.id === taskId);
21
+ return task ? cloneScheduledTask(task) : null;
22
+ }
23
+ export async function addScheduledTask(task) {
24
+ await mutateScheduledTasks((tasks) => ({
25
+ tasks: [...tasks, cloneScheduledTask(task)],
26
+ result: undefined,
27
+ }));
28
+ logger.info(`[ScheduledTaskStore] Added scheduled task: id=${task.id}, kind=${task.kind}`);
29
+ }
30
+ export async function replaceScheduledTasks(tasks) {
31
+ await mutateScheduledTasks(() => ({
32
+ tasks: tasks.map((task) => cloneScheduledTask(task)),
33
+ result: undefined,
34
+ }));
35
+ logger.info(`[ScheduledTaskStore] Replaced scheduled task collection: count=${tasks.length}`);
36
+ }
37
+ export async function removeScheduledTask(taskId) {
38
+ const removed = await mutateScheduledTasks((tasks) => {
39
+ const nextTasks = tasks.filter((task) => task.id !== taskId);
40
+ return {
41
+ tasks: nextTasks,
42
+ result: nextTasks.length !== tasks.length,
43
+ };
44
+ });
45
+ if (!removed) {
46
+ return false;
47
+ }
48
+ logger.info(`[ScheduledTaskStore] Removed scheduled task: id=${taskId}`);
49
+ return true;
50
+ }
51
+ export async function updateScheduledTask(taskId, updater) {
52
+ return mutateScheduledTasks((tasks) => {
53
+ const index = tasks.findIndex((task) => task.id === taskId);
54
+ if (index < 0) {
55
+ return { tasks, result: null };
56
+ }
57
+ const nextTask = cloneScheduledTask(updater(cloneScheduledTask(tasks[index])));
58
+ const nextTasks = [...tasks];
59
+ nextTasks[index] = nextTask;
60
+ return {
61
+ tasks: nextTasks,
62
+ result: cloneScheduledTask(nextTask),
63
+ };
64
+ });
65
+ }
@@ -0,0 +1,19 @@
1
+ export function createScheduledTaskModel(model) {
2
+ return {
3
+ providerID: model.providerID,
4
+ modelID: model.modelID,
5
+ variant: model.variant ?? null,
6
+ };
7
+ }
8
+ export function cloneParsedTaskSchedule(schedule) {
9
+ return { ...schedule };
10
+ }
11
+ export function cloneScheduledTaskModel(model) {
12
+ return { ...model };
13
+ }
14
+ export function cloneScheduledTask(task) {
15
+ return {
16
+ ...task,
17
+ model: cloneScheduledTaskModel(task.model),
18
+ };
19
+ }