@fickydev/pigent 0.1.9 → 0.1.10

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.
package/pigent.yaml CHANGED
@@ -1,5 +1,8 @@
1
1
  telegramChats: []
2
2
  modelChoices: []
3
+ scheduler:
4
+ tickIntervalMs: 10000
5
+ tasks: []
3
6
 
4
7
  # Optional. If empty, /model uses Pi's currently available models automatically.
5
8
  # Add choices to curate or rename buttons for /model.
@@ -20,3 +23,16 @@ modelChoices: []
20
23
  # - assistant
21
24
  # instructions: |
22
25
  # Be concise.
26
+ #
27
+ # Scheduled tasks. Each task runs on interval and sends output to channel.
28
+ # If the agent returns "NOOP", nothing is sent.
29
+ #
30
+ # scheduler:
31
+ # tickIntervalMs: 10000
32
+ # tasks:
33
+ # - id: monitor-build
34
+ # agent: assistant
35
+ # intervalMs: 30000
36
+ # prompt: Check the build pipeline. NOOP if nothing to report.
37
+ # channel: telegram
38
+ # chatId: "-100123456"
@@ -4,6 +4,7 @@ import type { Repositories } from "../db/repositories";
4
4
  import { getAvailableModelChoices } from "../pi/PiAvailableModels";
5
5
  import { isValidModelRef } from "../pi/PiModelResolver";
6
6
  import type { AgentRegistry } from "./AgentRegistry";
7
+ import type { Scheduler } from "../daemon/Scheduler";
7
8
 
8
9
  export type BotCommandResult =
9
10
  | {
@@ -23,12 +24,15 @@ export class BotCommandHandler {
23
24
  private readonly registry: AgentRegistry,
24
25
  private readonly repositories: Repositories,
25
26
  private readonly modelChoices: ModelChoiceConfig[] = [],
27
+ private readonly scheduler?: Scheduler,
26
28
  ) {}
27
29
 
28
30
  async handle(message: InboundMessage): Promise<BotCommandResult> {
29
31
  const [command] = message.text.trim().split(/\s+/, 1);
30
32
 
31
33
  switch (command) {
34
+ case "/task":
35
+ return await this.taskResult(message);
32
36
  case "/help":
33
37
  case "/start":
34
38
  return { handled: true, text: this.helpText() };
@@ -62,6 +66,9 @@ export class BotCommandHandler {
62
66
  "/thinking - show thinking picker for this chat session",
63
67
  "/thinking <off|low|medium|high> - set thinking level for this chat session",
64
68
  "/thinking default - clear thinking override for this chat session",
69
+ "/task list - list scheduled tasks for this chat",
70
+ "/task create <intervalMs> <prompt> - create a scheduled task",
71
+ "/task remove <id> - remove a scheduled task",
65
72
  "/agent <agentId> <message> - send message to specific agent",
66
73
  "@agentId <message> - send message to specific agent",
67
74
  ].join("\n");
@@ -84,8 +91,8 @@ export class BotCommandHandler {
84
91
  }
85
92
 
86
93
  return [
87
- `Default agent: ${chat.defaultAgentId ?? "none"}`,
88
- `Allowed agents: ${allowedAgentIds.length > 0 ? allowedAgentIds.join(", ") : "none"}`,
94
+ "Default agent: " + (chat.defaultAgentId ?? "none"),
95
+ "Allowed agents: " + (allowedAgentIds.length > 0 ? allowedAgentIds.join(", ") : "none"),
89
96
  ].join("\n");
90
97
  }
91
98
 
@@ -102,8 +109,8 @@ export class BotCommandHandler {
102
109
  return {
103
110
  handled: true,
104
111
  text: [
105
- `Agent: ${sessionResult.agentId}`,
106
- `Session model: ${sessionResult.session.model ?? "default"}`,
112
+ "Agent: " + sessionResult.agentId,
113
+ "Session model: " + (sessionResult.session.model ?? "default"),
107
114
  choices.length > 0
108
115
  ? "Choose a model:"
109
116
  : "No available Pi models found. Configure provider auth or use /model <provider/modelId> manually.",
@@ -121,7 +128,7 @@ export class BotCommandHandler {
121
128
  }
122
129
 
123
130
  const session = await this.repositories.sessions.updateModel(sessionResult.session.id, value);
124
- return { handled: true, text: `Session model for ${session.agentId} set to ${session.model}.` };
131
+ return { handled: true, text: "Session model for " + session.agentId + " set to " + session.model + "." };
125
132
  }
126
133
 
127
134
  private async thinkingResult(message: InboundMessage): Promise<BotCommandResult> {
@@ -135,8 +142,8 @@ export class BotCommandHandler {
135
142
  return {
136
143
  handled: true,
137
144
  text: [
138
- `Agent: ${sessionResult.agentId}`,
139
- `Session thinking level: ${sessionResult.session.thinkingLevel ?? "default"}`,
145
+ "Agent: " + sessionResult.agentId,
146
+ "Session thinking level: " + (sessionResult.session.thinkingLevel ?? "default"),
140
147
  "Choose thinking level:",
141
148
  ].join("\n"),
142
149
  inlineKeyboard: this.thinkingKeyboard(),
@@ -152,7 +159,7 @@ export class BotCommandHandler {
152
159
  }
153
160
 
154
161
  const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, value);
155
- return { handled: true, text: `Session thinking level for ${session.agentId} set to ${session.thinkingLevel}.` };
162
+ return { handled: true, text: "Session thinking level for " + session.agentId + " set to " + session.thinkingLevel + "." };
156
163
  }
157
164
 
158
165
  private async setModelFromChoice(message: InboundMessage): Promise<string> {
@@ -167,7 +174,7 @@ export class BotCommandHandler {
167
174
 
168
175
  const choice = choices[choiceIndex];
169
176
  const session = await this.repositories.sessions.updateModel(sessionResult.session.id, choice.id);
170
- return `Session model for ${session.agentId} set to ${choice.label} (${session.model}).`;
177
+ return "Session model for " + session.agentId + " set to " + choice.label + " (" + session.model + ").";
171
178
  }
172
179
 
173
180
  private async clearModel(message: InboundMessage): Promise<string> {
@@ -175,7 +182,7 @@ export class BotCommandHandler {
175
182
  if (!sessionResult.ok) return sessionResult.message;
176
183
 
177
184
  const session = await this.repositories.sessions.updateModel(sessionResult.session.id, null);
178
- return `Session model cleared for ${session.agentId}. Current: ${session.model ?? "default"}`;
185
+ return "Session model cleared for " + session.agentId + ". Current: " + (session.model ?? "default");
179
186
  }
180
187
 
181
188
  private async setThinkingFromCallback(message: InboundMessage): Promise<string> {
@@ -188,7 +195,7 @@ export class BotCommandHandler {
188
195
  }
189
196
 
190
197
  const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, value);
191
- return `Session thinking level for ${session.agentId} set to ${session.thinkingLevel}.`;
198
+ return "Session thinking level for " + session.agentId + " set to " + session.thinkingLevel + ".";
192
199
  }
193
200
 
194
201
  private async clearThinking(message: InboundMessage): Promise<string> {
@@ -196,7 +203,94 @@ export class BotCommandHandler {
196
203
  if (!sessionResult.ok) return sessionResult.message;
197
204
 
198
205
  const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, null);
199
- return `Session thinking level cleared for ${session.agentId}. Current: ${session.thinkingLevel ?? "default"}`;
206
+ return "Session thinking level cleared for " + session.agentId + ". Current: " + (session.thinkingLevel ?? "default");
207
+ }
208
+
209
+ private async taskResult(message: InboundMessage): Promise<BotCommandResult> {
210
+ if (!this.scheduler) {
211
+ return { handled: true, text: "Scheduler is not available." };
212
+ }
213
+
214
+ const parts = message.text.trim().split(/\s+/);
215
+ const subcommand = parts[1];
216
+
217
+ if (subcommand === "list") {
218
+ return await this.taskList(message);
219
+ }
220
+
221
+ if (subcommand === "create") {
222
+ return await this.taskCreate(message, parts);
223
+ }
224
+
225
+ if (subcommand === "remove") {
226
+ return await this.taskRemove(parts);
227
+ }
228
+
229
+ return {
230
+ handled: true,
231
+ text: [
232
+ "Usage:",
233
+ "/task list - list scheduled tasks",
234
+ "/task create <intervalMs> <prompt> - create a task",
235
+ "/task remove <id> - remove a task",
236
+ ].join("\n"),
237
+ };
238
+ }
239
+
240
+ private async taskList(message: InboundMessage): Promise<BotCommandResult> {
241
+ const tasks = this.scheduler!.getTasks().filter((t) => t.chatId === message.chatId || !t.chatId);
242
+ if (tasks.length === 0) return { handled: true, text: "No scheduled tasks for this chat." };
243
+
244
+ const lines = tasks.map((t) => {
245
+ const truncated = t.prompt.length > 50 ? t.prompt.slice(0, 50) + "..." : t.prompt;
246
+ return "- " + t.id + ": " + t.agent + " every " + t.intervalMs + 'ms "' + truncated + '"';
247
+ });
248
+
249
+ return { handled: true, text: "Scheduled tasks:\n" + lines.join("\n") };
250
+ }
251
+
252
+ private async taskCreate(message: InboundMessage, parts: string[]): Promise<BotCommandResult> {
253
+ const intervalMs = Number(parts[2]);
254
+ if (!Number.isInteger(intervalMs) || intervalMs < 1000) {
255
+ return { handled: true, text: "Invalid interval. Use a number >= 1000 (milliseconds)." };
256
+ }
257
+
258
+ const prompt = parts.slice(3).join(" ").trim();
259
+ if (!prompt) {
260
+ return { handled: true, text: "Missing prompt. Usage: /task create <intervalMs> <prompt>" };
261
+ }
262
+
263
+ const chat = await this.repositories.telegram.findChat(message.chatId);
264
+ if (!chat?.defaultAgentId) {
265
+ return { handled: true, text: "No default agent configured for this chat. Set one first." };
266
+ }
267
+
268
+ const saved = await this.scheduler!.addDynamicTask({
269
+ agent: chat.defaultAgentId,
270
+ intervalMs,
271
+ prompt,
272
+ channel: "telegram",
273
+ chatId: message.chatId,
274
+ });
275
+
276
+ const msg = "Task created: " + saved.id + "\n"
277
+ + "Agent: " + saved.agent + "\n"
278
+ + "Interval: " + saved.intervalMs + "ms\n"
279
+ + "Prompt: " + saved.prompt;
280
+
281
+ return { handled: true, text: msg };
282
+ }
283
+
284
+ private async taskRemove(parts: string[]): Promise<BotCommandResult> {
285
+ const taskId = parts[2];
286
+ if (!taskId) {
287
+ return { handled: true, text: "Missing task id. Usage: /task remove <id>" };
288
+ }
289
+
290
+ const removed = await this.scheduler!.removeDynamicTask(taskId);
291
+ if (!removed) return { handled: true, text: "Task not found: " + taskId };
292
+
293
+ return { handled: true, text: "Task removed: " + taskId };
200
294
  }
201
295
 
202
296
  private async loadModelChoices(): Promise<ModelChoiceConfig[]> {
@@ -209,7 +303,7 @@ export class BotCommandHandler {
209
303
  const rows = chunk(
210
304
  choices.map((choice, index) => ({
211
305
  text: choice.label,
212
- callbackData: `model:set:${index}`,
306
+ callbackData: "model:set:" + index,
213
307
  })),
214
308
  1,
215
309
  );
@@ -250,11 +344,11 @@ export class BotCommandHandler {
250
344
  }
251
345
 
252
346
  if (!this.registry.hasAgent(agentId)) {
253
- return { ok: false as const, message: `Unknown agent: ${agentId}` };
347
+ return { ok: false as const, message: "Unknown agent: " + agentId };
254
348
  }
255
349
 
256
350
  if (!(await this.repositories.telegram.isAgentAllowed(message.chatId, agentId))) {
257
- return { ok: false as const, message: `Agent is not allowed in this chat: ${agentId}` };
351
+ return { ok: false as const, message: "Agent is not allowed in this chat: " + agentId };
258
352
  }
259
353
 
260
354
  const session = await this.repositories.sessions.getOrCreate({
@@ -112,5 +112,6 @@ export async function loadConfig(options: LoadConfigOptions = {}): Promise<Loade
112
112
  profiles,
113
113
  telegramChats: rootConfig.telegramChats,
114
114
  modelChoices: rootConfig.modelChoices,
115
+ scheduler: rootConfig.scheduler,
115
116
  };
116
117
  }
@@ -11,10 +11,18 @@ export const PermissionConfigSchema = z.object({
11
11
  blockedTools: z.array(z.string()).default([]),
12
12
  });
13
13
 
14
- export const HeartbeatConfigSchema = z.object({
15
- enabled: z.boolean().default(false),
14
+ export const TaskConfigSchema = z.object({
15
+ id: z.string().min(1),
16
+ agent: z.string().min(1),
16
17
  intervalMs: z.number().int().positive().default(600_000),
17
18
  prompt: z.string().min(1).default("If no useful action is needed, reply exactly: NOOP."),
19
+ channel: z.string().default("telegram"),
20
+ chatId: z.string().optional(),
21
+ });
22
+
23
+ export const SchedulerConfigSchema = z.object({
24
+ tickIntervalMs: z.number().int().positive().default(10_000),
25
+ tasks: z.array(TaskConfigSchema).default([]),
18
26
  });
19
27
 
20
28
  export const AgentTelegramChannelConfigSchema = z.object({
@@ -36,11 +44,6 @@ export const AgentConfigSchema = z.object({
36
44
  skills: z.array(z.string()).default([]),
37
45
  extensions: z.array(z.string()).default([]),
38
46
  channels: AgentChannelsConfigSchema.default({ telegram: { enabled: false } }),
39
- heartbeat: HeartbeatConfigSchema.default({
40
- enabled: false,
41
- intervalMs: 600_000,
42
- prompt: "If no useful action is needed, reply exactly: NOOP.",
43
- }),
44
47
  permissions: PermissionConfigSchema.default({
45
48
  canRunShell: false,
46
49
  canEditFiles: false,
@@ -82,10 +85,12 @@ export const TelegramChatConfigSchema = z.object({
82
85
  export const RootConfigSchema = z.object({
83
86
  telegramChats: z.array(TelegramChatConfigSchema).default([]),
84
87
  modelChoices: z.array(ModelChoiceConfigSchema).default([]),
88
+ scheduler: SchedulerConfigSchema.default({ tickIntervalMs: 10_000, tasks: [] }),
85
89
  });
86
90
 
87
91
  export type PermissionConfig = z.infer<typeof PermissionConfigSchema>;
88
- export type HeartbeatConfig = z.infer<typeof HeartbeatConfigSchema>;
92
+ export type TaskConfig = z.infer<typeof TaskConfigSchema>;
93
+ export type SchedulerConfig = z.infer<typeof SchedulerConfigSchema>;
89
94
  export type AgentConfig = z.infer<typeof AgentConfigSchema>;
90
95
  export type ProfileConfig = z.infer<typeof ProfileConfigSchema>;
91
96
  export type TelegramChatConfig = z.infer<typeof TelegramChatConfigSchema>;
@@ -102,4 +107,5 @@ export type LoadedConfig = {
102
107
  profiles: ProfileConfig[];
103
108
  telegramChats: TelegramChatConfig[];
104
109
  modelChoices: ModelChoiceConfig[];
110
+ scheduler: SchedulerConfig;
105
111
  };
@@ -9,6 +9,7 @@ import { loadConfig } from "../config/loadConfig";
9
9
  import type { LoadedConfig } from "../config/schemas";
10
10
  import { createRepositories, type Repositories } from "../db/repositories";
11
11
  import { logger } from "../logging/logger";
12
+ import { Scheduler } from "./Scheduler";
12
13
 
13
14
  export class AgentDaemon {
14
15
  private running = false;
@@ -17,6 +18,7 @@ export class AgentDaemon {
17
18
  private readonly registry: AgentRegistry;
18
19
  private readonly router: MessageRouter;
19
20
  private readonly runner: AgentRunner;
21
+ private readonly scheduler: Scheduler;
20
22
 
21
23
  private constructor(
22
24
  private readonly config: LoadedConfig,
@@ -24,7 +26,8 @@ export class AgentDaemon {
24
26
  ) {
25
27
  this.adapters = createAdapters(repositories);
26
28
  this.registry = new AgentRegistry(config, repositories);
27
- this.commands = new BotCommandHandler(this.registry, repositories, config.modelChoices);
29
+ this.scheduler = new Scheduler(config.scheduler, this.registry, repositories, this.adapters);
30
+ this.commands = new BotCommandHandler(this.registry, repositories, config.modelChoices, this.scheduler);
28
31
  this.router = new MessageRouter(repositories, this.registry);
29
32
  this.runner = new AgentRunner(this.registry, repositories);
30
33
  }
@@ -45,6 +48,8 @@ export class AgentDaemon {
45
48
  await adapter.start((message) => this.handleInboundMessage(message));
46
49
  }
47
50
 
51
+ await this.scheduler.start();
52
+
48
53
  logger.info("pigent daemon started", {
49
54
  agents: this.registry.listAgents().length,
50
55
  profiles: this.registry.listProfiles().length,
@@ -58,6 +63,8 @@ export class AgentDaemon {
58
63
 
59
64
  this.running = false;
60
65
 
66
+ this.scheduler.stop();
67
+
61
68
  for (const adapter of this.adapters) {
62
69
  await adapter.stop();
63
70
  }
@@ -0,0 +1,224 @@
1
+ import type { SchedulerConfig, TaskConfig } from "../config/schemas";
2
+ import type { Repositories } from "../db/repositories";
3
+ import type { TaskConfigRow } from "../db/schema";
4
+ import { logger } from "../logging/logger";
5
+ import { PiAgentRunner } from "../pi/PiAgentRunner";
6
+ import type { AgentRegistry } from "../agents/AgentRegistry";
7
+ import type { ChannelAdapter } from "../channels/types";
8
+ import { isTaskDue } from "./taskDue";
9
+
10
+ export class Scheduler {
11
+ private running = false;
12
+ private tickTimer: ReturnType<typeof setTimeout> | null = null;
13
+ private readonly taskLocks = new Map<string, boolean>();
14
+ private readonly piRunner = new PiAgentRunner();
15
+ private tasks: TaskConfig[] = [];
16
+
17
+ constructor(
18
+ private readonly schedulerConfig: SchedulerConfig,
19
+ private readonly registry: AgentRegistry,
20
+ private readonly repositories: Repositories,
21
+ private readonly adapters: ChannelAdapter[],
22
+ ) {}
23
+
24
+ async start(): Promise<void> {
25
+ if (this.running) return;
26
+
27
+ this.tasks = await this.loadAllTasks();
28
+
29
+ if (this.tasks.length === 0) {
30
+ logger.info("scheduler skipped — no tasks configured");
31
+ return;
32
+ }
33
+
34
+ this.running = true;
35
+ logger.info("scheduler started", {
36
+ tickIntervalMs: this.schedulerConfig.tickIntervalMs,
37
+ taskCount: this.tasks.length,
38
+ });
39
+ this.tick();
40
+ }
41
+
42
+ stop(): void {
43
+ if (!this.running) return;
44
+ this.running = false;
45
+ if (this.tickTimer !== null) {
46
+ clearTimeout(this.tickTimer);
47
+ this.tickTimer = null;
48
+ }
49
+ logger.info("scheduler stopped");
50
+ }
51
+
52
+ getTasks(): TaskConfig[] {
53
+ return [...this.tasks];
54
+ }
55
+
56
+ /** Add a runtime-created task (from Telegram). Saves to DB, hot-adds to scheduler. */
57
+ async addDynamicTask(input: {
58
+ agent: string;
59
+ intervalMs: number;
60
+ prompt: string;
61
+ channel: string;
62
+ chatId: string;
63
+ }): Promise<TaskConfigRow> {
64
+ const saved = await this.repositories.taskConfigs.create(input);
65
+
66
+ this.tasks.push({
67
+ id: saved.id,
68
+ agent: saved.agent,
69
+ intervalMs: saved.intervalMs,
70
+ prompt: saved.prompt,
71
+ channel: saved.channel,
72
+ chatId: saved.chatId,
73
+ });
74
+
75
+ if (!this.running && this.tasks.length === 1) {
76
+ // First task added, start scheduler
77
+ this.running = true;
78
+ this.tick();
79
+ logger.info("scheduler started on first dynamic task");
80
+ }
81
+
82
+ return saved;
83
+ }
84
+
85
+ /** Remove a runtime task. Deletes from DB, hot-removes from scheduler. */
86
+ async removeDynamicTask(id: string): Promise<boolean> {
87
+ const before = this.tasks.length;
88
+ this.tasks = this.tasks.filter((t) => t.id !== id);
89
+ await this.repositories.taskConfigs.remove(id);
90
+
91
+ const removed = this.tasks.length < before;
92
+
93
+ if (removed && this.tasks.length === 0 && this.running) {
94
+ this.stop();
95
+ }
96
+
97
+ return removed;
98
+ }
99
+
100
+ private async loadAllTasks(): Promise<TaskConfig[]> {
101
+ const configTasks: TaskConfig[] = this.schedulerConfig.tasks;
102
+ const dbTasks = await this.repositories.taskConfigs.findAllEnabled();
103
+
104
+ const dbTaskConfigs: TaskConfig[] = dbTasks.map((row) => ({
105
+ id: row.id,
106
+ agent: row.agent,
107
+ intervalMs: row.intervalMs,
108
+ prompt: row.prompt,
109
+ channel: row.channel,
110
+ chatId: row.chatId,
111
+ }));
112
+
113
+ return [...configTasks, ...dbTaskConfigs];
114
+ }
115
+
116
+ private tick(): void {
117
+ if (!this.running) return;
118
+
119
+ for (const task of this.tasks) {
120
+ this.runTaskIfDue(task);
121
+ }
122
+
123
+ this.tickTimer = setTimeout(() => this.tick(), this.schedulerConfig.tickIntervalMs);
124
+ }
125
+
126
+ private async runTaskIfDue(task: TaskConfig): Promise<void> {
127
+ const lockKey = task.id;
128
+ if (this.taskLocks.get(lockKey)) return;
129
+
130
+ const lastRun = await this.repositories.taskRuns.findLatest(task.agent, task.id);
131
+ if (!isTaskDue(lastRun?.createdAt ?? null, task.intervalMs)) return;
132
+
133
+ const agent = this.registry.getAgent(task.agent);
134
+ if (!agent) {
135
+ logger.warn("task references unknown agent, skipping", { taskId: task.id, agent: task.agent });
136
+ return;
137
+ }
138
+
139
+ this.taskLocks.set(lockKey, true);
140
+
141
+ try {
142
+ await this.executeTask(task, agent);
143
+ } finally {
144
+ this.taskLocks.set(lockKey, false);
145
+ }
146
+ }
147
+
148
+ private async executeTask(task: TaskConfig, agent: any): Promise<void> {
149
+ logger.info("task executing", { taskId: task.id, agentId: task.agent });
150
+
151
+ const run = await this.repositories.taskRuns.create({
152
+ agentId: task.agent,
153
+ taskId: task.id,
154
+ prompt: task.prompt,
155
+ });
156
+
157
+ await this.repositories.taskRuns.markRunning(run.id);
158
+
159
+ try {
160
+ const session = await this.repositories.sessions.getOrCreate({
161
+ agentId: task.agent,
162
+ channel: task.channel,
163
+ chatId: task.chatId ?? `task:${task.id}`,
164
+ threadId: null,
165
+ });
166
+
167
+ const fullPrompt = this.composeTaskPrompt(task);
168
+ const profile = this.registry.getProfile(agent.profile);
169
+
170
+ const response = await this.piRunner.run({
171
+ agent,
172
+ profile,
173
+ session,
174
+ prompt: fullPrompt,
175
+ });
176
+
177
+ const trimmed = response.trim();
178
+
179
+ if (trimmed.toUpperCase() === "NOOP") {
180
+ await this.repositories.taskRuns.markNoop(run.id, { result: response });
181
+ logger.info("task noop", { taskId: task.id });
182
+ return;
183
+ }
184
+
185
+ await this.repositories.taskRuns.markNotified(run.id, { result: response });
186
+ await this.sendToChannel(task, agent, response);
187
+ } catch (error) {
188
+ const errorMessage = error instanceof Error ? error.message : String(error);
189
+ logger.error("task failed", { taskId: task.id, error: errorMessage });
190
+ await this.repositories.taskRuns.markFailed(run.id, { error: errorMessage });
191
+ }
192
+ }
193
+
194
+ private async sendToChannel(task: TaskConfig, agent: any, text: string): Promise<void> {
195
+ const adapter = this.adapters.find((a) => a.id === task.channel);
196
+ if (!adapter) {
197
+ logger.warn("no adapter for task output", { taskId: task.id, channel: task.channel });
198
+ return;
199
+ }
200
+
201
+ if (!task.chatId) {
202
+ logger.warn("no chatId for task output", { taskId: task.id });
203
+ return;
204
+ }
205
+
206
+ await adapter.send({
207
+ channel: task.channel as any,
208
+ chatId: task.chatId,
209
+ text: `[${agent.name} / ${task.id}]\n${text}`,
210
+ });
211
+ }
212
+
213
+ private composeTaskPrompt(task: TaskConfig): string {
214
+ return [
215
+ `[Task: ${task.id}]`,
216
+ "",
217
+ task.prompt,
218
+ "",
219
+ "You are running as a scheduled task inside Pigent.",
220
+ "If no useful action is needed, reply exactly: NOOP.",
221
+ "Otherwise, reply with the result of your task.",
222
+ ].join("\n");
223
+ }
224
+ }
@@ -0,0 +1,4 @@
1
+ export function isTaskDue(lastRunCreatedAt: number | null, intervalMs: number, now = Date.now()): boolean {
2
+ if (lastRunCreatedAt === null) return true;
3
+ return now - lastRunCreatedAt >= intervalMs;
4
+ }
@@ -0,0 +1,63 @@
1
+ import { eq } from "drizzle-orm";
2
+ import { nanoid } from "nanoid";
3
+ import type { DbClient } from "../client";
4
+ import { taskConfigs, type TaskConfigRow } from "../schema";
5
+
6
+ export type CreateTaskConfigInput = {
7
+ agent: string;
8
+ intervalMs: number;
9
+ prompt: string;
10
+ channel: string;
11
+ chatId: string;
12
+ };
13
+
14
+ export class TaskConfigRepository {
15
+ constructor(private readonly db: DbClient) {}
16
+
17
+ async create(input: CreateTaskConfigInput): Promise<TaskConfigRow> {
18
+ const id = nanoid(8);
19
+ const now = Date.now();
20
+
21
+ await this.db.insert(taskConfigs).values({
22
+ id,
23
+ agent: input.agent,
24
+ intervalMs: input.intervalMs,
25
+ prompt: input.prompt,
26
+ channel: input.channel,
27
+ chatId: input.chatId,
28
+ enabled: true,
29
+ createdAt: now,
30
+ updatedAt: now,
31
+ });
32
+
33
+ const row = await this.findById(id);
34
+ if (!row) throw new Error(`failed to create task config ${id}`);
35
+
36
+ return row;
37
+ }
38
+
39
+ async remove(id: string): Promise<void> {
40
+ await this.db.delete(taskConfigs).where(eq(taskConfigs.id, id));
41
+ }
42
+
43
+ async findById(id: string): Promise<TaskConfigRow | null> {
44
+ const row = await this.db.query.taskConfigs.findFirst({
45
+ where: eq(taskConfigs.id, id),
46
+ });
47
+
48
+ return row ?? null;
49
+ }
50
+
51
+ async findAllByChatId(chatId: string): Promise<TaskConfigRow[]> {
52
+ return this.db.query.taskConfigs.findMany({
53
+ where: eq(taskConfigs.chatId, chatId),
54
+ orderBy: (fields, { asc }) => [asc(fields.createdAt)],
55
+ });
56
+ }
57
+
58
+ async findAllEnabled(): Promise<TaskConfigRow[]> {
59
+ return this.db.query.taskConfigs.findMany({
60
+ where: eq(taskConfigs.enabled, true),
61
+ });
62
+ }
63
+ }