niahere 0.3.2 → 0.3.4

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.
@@ -1,4 +1,4 @@
1
- import { Bot, InputFile } from "grammy";
1
+ import { Bot, type Context, InputFile } from "grammy";
2
2
  import { createHash } from "crypto";
3
3
  import { mkdirSync, writeFileSync } from "fs";
4
4
  import { join } from "path";
@@ -26,6 +26,36 @@ class TelegramChannel implements Channel {
26
26
  name = "telegram" as const;
27
27
  private bot: Bot | null = null;
28
28
  private outboundChatId: number | null = null;
29
+ private isOpen = false;
30
+ private readonly chats = new Map<number, ChatState>();
31
+
32
+ async start(): Promise<void> {
33
+ const config = getConfig();
34
+ const token = config.channels.telegram.bot_token!;
35
+
36
+ await runMigrations();
37
+
38
+ this.outboundChatId = config.channels.telegram.chat_id;
39
+ this.isOpen = config.channels.telegram.open;
40
+
41
+ const bot = new Bot(token);
42
+ this.bot = bot;
43
+
44
+ bot.command("start", (ctx) => this.handleStart(ctx));
45
+ bot.command(["restart", "new"], (ctx) => this.handleRestart(ctx));
46
+ bot.on("message:text", (ctx) => this.handleText(ctx));
47
+ bot.on("message:photo", (ctx) => this.handlePhoto(ctx));
48
+ bot.on("message:document", (ctx) => this.handleDocument(ctx));
49
+
50
+ bot.start({ onStart: () => log.info("telegram bot polling started") });
51
+ }
52
+
53
+ async stop(): Promise<void> {
54
+ if (this.bot) {
55
+ this.bot.stop();
56
+ this.bot = null;
57
+ }
58
+ }
29
59
 
30
60
  async deliver(out: Outbound): Promise<void> {
31
61
  if (!this.bot) throw new Error("Telegram not started");
@@ -47,236 +77,211 @@ class TelegramChannel implements Channel {
47
77
  }
48
78
  }
49
79
 
50
- private async downloadFile(fileId: string): Promise<Buffer> {
51
- if (!this.bot) throw new Error("Telegram not started");
52
- const file = await this.bot.api.getFile(fileId);
53
- const token = getConfig().channels.telegram.bot_token!;
54
- const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
55
- const resp = await fetch(url);
56
- if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
57
- return Buffer.from(await resp.arrayBuffer());
80
+ // --- Inbound handlers ---
81
+
82
+ private async handleStart(ctx: Context): Promise<void> {
83
+ if (!ctx.chatId || !this.gate(ctx)) return;
84
+ this.registerOutbound(ctx.chatId);
85
+ const state = await this.getState(ctx.chatId);
86
+ this.withLock(ctx.chatId, () => this.processMessage(ctx, state, "hi"));
58
87
  }
59
88
 
60
- private cacheAttachment(
61
- chatId: number,
62
- roomIndex: number,
63
- data: Buffer,
64
- mimeType: string,
65
- filename?: string,
66
- ): string {
67
- const scope = `telegram-${chatId}-${roomIndex}`;
68
- const dir = join(getNiaHome(), "tmp", "attachments", scope);
69
- mkdirSync(dir, { recursive: true });
70
- const ext = cacheExtension(filename, mimeType);
71
- const hash = createHash("sha256").update(data).digest("hex").slice(0, 16);
72
- const path = join(dir, `${hash}.${ext}`);
73
- writeFileSync(path, data);
74
- return path;
89
+ private async handleRestart(ctx: Context): Promise<void> {
90
+ if (!ctx.chatId || !this.gate(ctx)) return;
91
+ this.registerOutbound(ctx.chatId);
92
+ const state = await this.restartChat(ctx.chatId);
93
+ log.info({ chatId: ctx.chatId, room: `tg-${ctx.chatId}-${state.roomIndex}` }, "new telegram conversation");
94
+ await ctx.reply("New conversation started.");
75
95
  }
76
96
 
77
- async start(): Promise<void> {
78
- const config = getConfig();
79
- const token = config.channels.telegram.bot_token!;
97
+ private async handleText(ctx: Context): Promise<void> {
98
+ if (!ctx.chatId || !ctx.message?.text || !this.gate(ctx)) return;
99
+ this.registerOutbound(ctx.chatId);
100
+ const state = await this.getState(ctx.chatId);
101
+ const text = ctx.message.text;
102
+ this.withLock(ctx.chatId, () => this.processMessage(ctx, state, text));
103
+ }
80
104
 
81
- await runMigrations();
105
+ private async handlePhoto(ctx: Context): Promise<void> {
106
+ if (!ctx.chatId || !ctx.message?.photo || !this.gate(ctx)) return;
107
+ this.registerOutbound(ctx.chatId);
108
+ const state = await this.getState(ctx.chatId);
109
+ const chatId = ctx.chatId;
110
+ this.withLock(chatId, async () => {
111
+ try {
112
+ const photos = ctx.message!.photo!;
113
+ const largest = photos[photos.length - 1];
114
+ const raw = await this.downloadFile(largest.file_id);
115
+ const { data, mimeType } = await prepareImage(raw, "image/jpeg");
116
+ const attachment: Attachment = { type: "image", data, mimeType };
117
+ const caption = ctx.message!.caption || "What's in this image?";
118
+ await this.processMessage(ctx, state, caption, [attachment]);
119
+ } catch (err) {
120
+ log.error({ err, chatId }, "failed to process photo");
121
+ await ctx.reply("Failed to process image.").catch(() => {});
122
+ }
123
+ });
124
+ }
82
125
 
83
- this.outboundChatId = config.channels.telegram.chat_id;
126
+ private async handleDocument(ctx: Context): Promise<void> {
127
+ if (!ctx.chatId || !ctx.message?.document || !this.gate(ctx)) return;
128
+ this.registerOutbound(ctx.chatId);
129
+ const state = await this.getState(ctx.chatId);
130
+ const chatId = ctx.chatId;
131
+ this.withLock(chatId, async () => {
132
+ try {
133
+ const doc = ctx.message!.document!;
134
+ const mime = doc.mime_type || "application/octet-stream";
135
+ const attType = classifyMime(mime) || "file";
136
+ let data = await this.downloadFile(doc.file_id);
137
+ const error = validateAttachment(data);
138
+ if (error) {
139
+ await ctx.reply(error);
140
+ return;
141
+ }
142
+ let finalMime = mime;
143
+ if (attType === "image") {
144
+ const prepared = await prepareImage(data, mime);
145
+ data = prepared.data;
146
+ finalMime = prepared.mimeType;
147
+ }
148
+ const sourcePath = this.cacheAttachment(chatId, state.roomIndex, data, finalMime, doc.file_name);
149
+ const attachment: Attachment = {
150
+ type: attType,
151
+ data,
152
+ mimeType: finalMime,
153
+ filename: doc.file_name,
154
+ sourcePath,
155
+ };
156
+ const caption = ctx.message!.caption || (attType === "image" ? "What's in this image?" : "Here's a file.");
157
+ await this.processMessage(ctx, state, caption, [attachment]);
158
+ } catch (err) {
159
+ log.error({ err, chatId }, "failed to process document");
160
+ await ctx.reply("Failed to process document.").catch(() => {});
161
+ }
162
+ });
163
+ }
84
164
 
85
- const chats = new Map<number, ChatState>();
165
+ // --- Core message loop ---
86
166
 
87
- function keyOf(chatId: number): string {
88
- return `tg-${chatId}`;
89
- }
167
+ private async processMessage(
168
+ ctx: Context,
169
+ state: ChatState,
170
+ text: string,
171
+ attachments?: Attachment[],
172
+ ): Promise<void> {
173
+ if (!this.bot) return;
174
+ const bot = this.bot;
175
+ const chatId = ctx.chatId!;
176
+ log.info({ chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 }, "telegram message received");
90
177
 
91
- async function getState(chatId: number): Promise<ChatState> {
92
- let state = chats.get(chatId);
93
- if (state) return state;
94
- state = await openChatEngine(keyOf(chatId), () => ({ channel: "telegram", mcpServers: getMcpServers() }));
95
- chats.set(chatId, state);
96
- return state;
97
- }
98
-
99
- async function restartChat(chatId: number): Promise<ChatState> {
100
- const state = await rotateRoom(keyOf(chatId), chats.get(chatId), () => ({
101
- channel: "telegram",
102
- mcpServers: getMcpServers(),
103
- }));
104
- chats.set(chatId, state);
105
- return state;
106
- }
178
+ const typingInterval = setInterval(() => {
179
+ bot.api.sendChatAction(chatId, "typing").catch(() => {});
180
+ }, 4000);
181
+ bot.api.sendChatAction(chatId, "typing").catch(() => {});
107
182
 
108
- function withLock(chatId: number, fn: () => Promise<void>): void {
109
- const state = chats.get(chatId);
110
- if (!state) {
111
- fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
112
- return;
183
+ try {
184
+ const { result, messageId } = await state.engine.send(text, {}, attachments);
185
+ const reply = result.trim() || "(no response)";
186
+ try {
187
+ await bot.api.sendMessage(chatId, reply);
188
+ if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
189
+ log.info({ chatId, chars: result.length }, "telegram reply sent");
190
+ } catch (sendErr) {
191
+ if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
192
+ throw sendErr;
113
193
  }
114
- chainLock(state, fn);
115
- }
116
-
117
- const isOpen = config.channels.telegram.open;
118
- const self = this;
119
-
120
- function registerOutbound(chatId: number): void {
121
- if (self.outboundChatId) return;
122
- self.outboundChatId = chatId;
123
- updateRawConfig({ channels: { telegram: { chat_id: chatId } } });
124
- log.info({ chatId }, "auto-registered outbound chat ID");
194
+ } catch (err) {
195
+ const errText = err instanceof Error ? err.message : String(err);
196
+ log.error({ err, chatId }, "telegram message processing failed");
197
+ await bot.api.sendMessage(chatId, `[error] ${errText}`).catch(() => {});
198
+ } finally {
199
+ clearInterval(typingInterval);
125
200
  }
201
+ }
126
202
 
127
- function isAllowed(chatId: number): boolean {
128
- if (isOpen) return true;
129
- if (!self.outboundChatId) return true; // first user always allowed (gets registered)
130
- return chatId === self.outboundChatId;
131
- }
203
+ // --- Session / state helpers ---
132
204
 
133
- const bot = new Bot(token);
205
+ private async getState(chatId: number): Promise<ChatState> {
206
+ let state = this.chats.get(chatId);
207
+ if (state) return state;
208
+ state = await openChatEngine(this.keyOf(chatId), () => ({ channel: "telegram", mcpServers: getMcpServers() }));
209
+ this.chats.set(chatId, state);
210
+ return state;
211
+ }
134
212
 
135
- async function processMessage(ctx: any, state: ChatState, text: string, attachments?: Attachment[]): Promise<void> {
136
- const chatId = ctx.chatId;
137
- log.info(
138
- { chatId, text: text.slice(0, 100), attachments: attachments?.length || 0 },
139
- "telegram message received",
140
- );
141
-
142
- // Show typing indicator throughout
143
- const typingInterval = setInterval(() => {
144
- bot.api.sendChatAction(chatId, "typing").catch(() => {});
145
- }, 4000);
146
- bot.api.sendChatAction(chatId, "typing").catch(() => {});
213
+ private async restartChat(chatId: number): Promise<ChatState> {
214
+ const state = await rotateRoom(this.keyOf(chatId), this.chats.get(chatId), () => ({
215
+ channel: "telegram",
216
+ mcpServers: getMcpServers(),
217
+ }));
218
+ this.chats.set(chatId, state);
219
+ return state;
220
+ }
147
221
 
148
- try {
149
- const { result, messageId } = await state.engine.send(text, {}, attachments);
150
-
151
- const reply = result.trim() || "(no response)";
152
- try {
153
- try {
154
- await bot.api.sendMessage(chatId, reply, { parse_mode: "MarkdownV2" });
155
- } catch {
156
- await bot.api.sendMessage(chatId, reply);
157
- }
158
- if (messageId) await Message.updateDeliveryStatus(messageId, "sent").catch(() => {});
159
- log.info({ chatId, chars: result.length }, "telegram reply sent");
160
- } catch (sendErr) {
161
- if (messageId) await Message.updateDeliveryStatus(messageId, "failed").catch(() => {});
162
- throw sendErr;
163
- }
164
- } catch (err) {
165
- const errText = err instanceof Error ? err.message : String(err);
166
- log.error({ err, chatId }, "telegram message processing failed");
167
- await bot.api.sendMessage(chatId, `[error] ${errText}`).catch(() => {});
168
- } finally {
169
- clearInterval(typingInterval);
170
- }
222
+ private withLock(chatId: number, fn: () => Promise<void>): void {
223
+ const state = this.chats.get(chatId);
224
+ if (!state) {
225
+ fn().catch((err) => log.error({ err, chatId }, "unhandled error in locked handler"));
226
+ return;
171
227
  }
228
+ chainLock(state, fn);
229
+ }
172
230
 
173
- bot.command("start", async (ctx) => {
174
- if (!isAllowed(ctx.chatId)) {
175
- await ctx.reply("Unauthorized.");
176
- return;
177
- }
178
- registerOutbound(ctx.chatId);
179
- const state = await getState(ctx.chatId);
180
- withLock(ctx.chatId, () => processMessage(ctx, state, "hi"));
181
- });
231
+ private keyOf(chatId: number): string {
232
+ return `tg-${chatId}`;
233
+ }
182
234
 
183
- bot.command(["restart", "new"], async (ctx) => {
184
- if (!isAllowed(ctx.chatId)) {
185
- await ctx.reply("Unauthorized.");
186
- return;
187
- }
188
- registerOutbound(ctx.chatId);
189
- const state = await restartChat(ctx.chatId);
190
- log.info({ chatId: ctx.chatId, room: `tg-${ctx.chatId}-${state.roomIndex}` }, "new telegram conversation");
191
- await ctx.reply("New conversation started.");
192
- });
235
+ // --- Authorization / outbound binding ---
193
236
 
194
- bot.on("message:text", async (ctx) => {
195
- if (!isAllowed(ctx.chatId)) {
196
- await ctx.reply("Unauthorized.");
197
- return;
198
- }
199
- registerOutbound(ctx.chatId);
200
- const state = await getState(ctx.chatId);
201
- withLock(ctx.chatId, () => processMessage(ctx, state, ctx.message.text));
202
- });
237
+ /** Authorization check + auto-reply with "Unauthorized." if not allowed. Returns true to continue. */
238
+ private gate(ctx: Context): boolean {
239
+ if (!ctx.chatId) return false;
240
+ if (this.isAllowed(ctx.chatId)) return true;
241
+ ctx.reply("Unauthorized.").catch(() => {});
242
+ return false;
243
+ }
203
244
 
204
- bot.on("message:photo", async (ctx) => {
205
- if (!isAllowed(ctx.chatId)) {
206
- await ctx.reply("Unauthorized.");
207
- return;
208
- }
209
- registerOutbound(ctx.chatId);
210
- const state = await getState(ctx.chatId);
211
- withLock(ctx.chatId, async () => {
212
- try {
213
- const photos = ctx.message.photo;
214
- const largest = photos[photos.length - 1];
215
- const raw = await self.downloadFile(largest.file_id);
216
- const { data, mimeType } = await prepareImage(raw, "image/jpeg");
217
- const attachment: Attachment = { type: "image", data, mimeType };
218
- const caption = ctx.message.caption || "What's in this image?";
219
- await processMessage(ctx, state, caption, [attachment]);
220
- } catch (err) {
221
- log.error({ err, chatId: ctx.chatId }, "failed to process photo");
222
- await ctx.reply("Failed to process image.").catch(() => {});
223
- }
224
- });
225
- });
245
+ private isAllowed(chatId: number): boolean {
246
+ if (this.isOpen) return true;
247
+ if (!this.outboundChatId) return true; // first user always allowed (gets registered)
248
+ return chatId === this.outboundChatId;
249
+ }
226
250
 
227
- bot.on("message:document", async (ctx) => {
228
- if (!isAllowed(ctx.chatId)) {
229
- await ctx.reply("Unauthorized.");
230
- return;
231
- }
232
- registerOutbound(ctx.chatId);
233
- const state = await getState(ctx.chatId);
234
- withLock(ctx.chatId, async () => {
235
- try {
236
- const doc = ctx.message.document;
237
- const mime = doc.mime_type || "application/octet-stream";
238
- const attType = classifyMime(mime) || "file";
239
- let data = await self.downloadFile(doc.file_id);
240
- const error = validateAttachment(data);
241
- if (error) {
242
- await ctx.reply(error);
243
- return;
244
- }
245
- let finalMime = mime;
246
- if (attType === "image") {
247
- const prepared = await prepareImage(data, mime);
248
- data = prepared.data;
249
- finalMime = prepared.mimeType;
250
- }
251
- const sourcePath = self.cacheAttachment(ctx.chatId, state.roomIndex, data, finalMime, doc.file_name);
252
- const attachment: Attachment = {
253
- type: attType,
254
- data,
255
- mimeType: finalMime,
256
- filename: doc.file_name,
257
- sourcePath,
258
- };
259
- const caption = ctx.message.caption || (attType === "image" ? "What's in this image?" : "Here's a file.");
260
- await processMessage(ctx, state, caption, [attachment]);
261
- } catch (err) {
262
- log.error({ err, chatId: ctx.chatId }, "failed to process document");
263
- await ctx.reply("Failed to process document.").catch(() => {});
264
- }
265
- });
266
- });
251
+ private registerOutbound(chatId: number): void {
252
+ if (this.outboundChatId) return;
253
+ this.outboundChatId = chatId;
254
+ updateRawConfig({ channels: { telegram: { chat_id: chatId } } });
255
+ log.info({ chatId }, "auto-registered outbound chat ID");
256
+ }
267
257
 
268
- bot.start({
269
- onStart: () => log.info("telegram bot polling started"),
270
- });
258
+ // --- File helpers ---
271
259
 
272
- this.bot = bot;
260
+ private async downloadFile(fileId: string): Promise<Buffer> {
261
+ if (!this.bot) throw new Error("Telegram not started");
262
+ const file = await this.bot.api.getFile(fileId);
263
+ const token = getConfig().channels.telegram.bot_token!;
264
+ const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
265
+ const resp = await fetch(url, { signal: AbortSignal.timeout(30_000) });
266
+ if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
267
+ return Buffer.from(await resp.arrayBuffer());
273
268
  }
274
269
 
275
- async stop(): Promise<void> {
276
- if (this.bot) {
277
- this.bot.stop();
278
- this.bot = null;
279
- }
270
+ private cacheAttachment(
271
+ chatId: number,
272
+ roomIndex: number,
273
+ data: Buffer,
274
+ mimeType: string,
275
+ filename?: string,
276
+ ): string {
277
+ const scope = `telegram-${chatId}-${roomIndex}`;
278
+ const dir = join(getNiaHome(), "tmp", "attachments", scope);
279
+ mkdirSync(dir, { recursive: true });
280
+ const ext = cacheExtension(filename, mimeType);
281
+ const hash = createHash("sha256").update(data).digest("hex").slice(0, 16);
282
+ const path = join(dir, `${hash}.${ext}`);
283
+ writeFileSync(path, data);
284
+ return path;
280
285
  }
281
286
  }
282
287
 
@@ -33,6 +33,7 @@ async function twilioPost<T = unknown>(creds: TwilioCreds, suffix: string, body:
33
33
  method: "POST",
34
34
  headers: { Authorization: basicAuth(creds), "Content-Type": "application/x-www-form-urlencoded" },
35
35
  body: body.toString(),
36
+ signal: AbortSignal.timeout(15_000),
36
37
  });
37
38
  if (!resp.ok) {
38
39
  const text = await resp.text().catch(() => "");
package/src/core/alive.ts CHANGED
@@ -107,6 +107,7 @@ async function notifyUser(message: string): Promise<void> {
107
107
  method: "POST",
108
108
  headers: { Authorization: `Bearer ${slToken}`, "Content-Type": "application/json" },
109
109
  body: JSON.stringify({ channel: slRecipient, text: message }),
110
+ signal: AbortSignal.timeout(10_000),
110
111
  });
111
112
  if (resp.ok) {
112
113
  log.info("alive: notified user via slack");
@@ -98,7 +98,10 @@ export async function setSummary(id: string, summary: string): Promise<void> {
98
98
  await sql`UPDATE sessions SET summary = ${summary} WHERE id = ${id}`;
99
99
  }
100
100
 
101
- export async function getRecentSummaries(room: string, limit = 3): Promise<Array<{ summary: string; updatedAt: string }>> {
101
+ export async function getRecentSummaries(
102
+ room: string,
103
+ limit = 3,
104
+ ): Promise<Array<{ summary: string; updatedAt: string }>> {
102
105
  const sql = getSql();
103
106
  // Match summaries from sessions in the same channel (e.g. slack-dm-U...-*)
104
107
  // by extracting the room prefix (everything before the last -N index)
@@ -171,17 +174,16 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
171
174
  `;
172
175
  }
173
176
 
177
+ /** Max numeric suffix among rooms matching `${prefix}-N`. Used by rotateRoom() to allocate idx+1 without collisions. */
174
178
  export async function getLatestRoomIndex(prefix: string): Promise<number> {
175
179
  const sql = getSql();
176
180
  const pattern = `^${escapeRegex(prefix)}-\\d+$`;
177
- const rows = await sql`
178
- SELECT room FROM sessions
179
- WHERE room ~ ${pattern}
180
- ORDER BY updated_at DESC
181
- LIMIT 1
182
- `;
183
- if (rows.length === 0) return 0;
184
- const parts = rows[0].room.split("-");
185
- const idx = parseInt(parts[parts.length - 1], 10);
186
- return isNaN(idx) ? 0 : idx;
181
+ const rows = await sql`SELECT room FROM sessions WHERE room ~ ${pattern}`;
182
+ let max = 0;
183
+ for (const row of rows) {
184
+ const parts = (row.room as string).split("-");
185
+ const idx = parseInt(parts[parts.length - 1], 10);
186
+ if (!isNaN(idx) && idx > max) max = idx;
187
+ }
188
+ return max;
187
189
  }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * MCP tool handler barrel. Domain modules live next to this file. Callers
3
+ * `import * as handlers from "./tools"` and get all of them.
4
+ */
5
+ export * from "./jobs";
6
+ export * from "./send";
7
+ export * from "./messages";
8
+ export * from "./watch";
9
+ export * from "./misc";
@@ -0,0 +1,145 @@
1
+ import { Job } from "../../db/models";
2
+ import { computeInitialNextRun } from "../../core/scheduler";
3
+ import { getConfig } from "../../utils/config";
4
+ import { resolveJobPrompt } from "../../core/job-prompt";
5
+ import type { ScheduleType } from "../../types";
6
+
7
+ export async function listJobs(): Promise<string> {
8
+ const jobs = await Job.list();
9
+ if (jobs.length === 0) return "No jobs found.";
10
+ const withPromptSource = jobs.map((job) => {
11
+ const resolvedPrompt = resolveJobPrompt(job);
12
+ return {
13
+ ...job,
14
+ prompt: resolvedPrompt.prompt,
15
+ promptSource: resolvedPrompt.source,
16
+ promptPath: resolvedPrompt.filePath,
17
+ };
18
+ });
19
+ return JSON.stringify(withPromptSource, null, 2);
20
+ }
21
+
22
+ export async function addJob(args: {
23
+ name: string;
24
+ schedule: string;
25
+ prompt: string;
26
+ schedule_type?: ScheduleType;
27
+ always?: boolean;
28
+ agent?: string;
29
+ employee?: string;
30
+ model?: string;
31
+ stateless?: boolean;
32
+ }): Promise<string> {
33
+ const scheduleType = args.schedule_type || "cron";
34
+ const always = args.always || false;
35
+ const stateless = args.stateless || false;
36
+ const config = getConfig();
37
+
38
+ const nextRunAt = computeInitialNextRun(scheduleType, args.schedule, config.timezone);
39
+ await Job.create(
40
+ args.name,
41
+ args.schedule,
42
+ args.prompt,
43
+ always,
44
+ scheduleType,
45
+ nextRunAt,
46
+ args.agent,
47
+ stateless,
48
+ args.model,
49
+ args.employee,
50
+ );
51
+ const agentNote = args.agent ? ` [agent: ${args.agent}]` : "";
52
+ const employeeNote = args.employee ? ` [employee: ${args.employee}]` : "";
53
+ const modelNote = args.model ? ` [model: ${args.model}]` : "";
54
+ return `Job "${args.name}" created (${scheduleType}: ${args.schedule})${agentNote}${employeeNote}${modelNote}. Next run: ${nextRunAt.toISOString()}`;
55
+ }
56
+
57
+ export async function updateJob(args: {
58
+ name: string;
59
+ schedule?: string;
60
+ prompt?: string;
61
+ always?: boolean;
62
+ agent?: string | null;
63
+ employee?: string | null;
64
+ model?: string | null;
65
+ stateless?: boolean;
66
+ schedule_type?: "cron" | "interval" | "once";
67
+ }): Promise<string> {
68
+ const fields: Partial<{
69
+ schedule: string;
70
+ prompt: string;
71
+ always: boolean;
72
+ stateless: boolean;
73
+ model: string | null;
74
+ agent: string | null;
75
+ employee: string | null;
76
+ scheduleType: "cron" | "interval" | "once";
77
+ }> = {};
78
+ if (args.schedule) fields.schedule = args.schedule;
79
+ if (args.prompt) fields.prompt = args.prompt;
80
+ if (args.always !== undefined) fields.always = args.always;
81
+ if (args.stateless !== undefined) fields.stateless = args.stateless;
82
+ if (args.model !== undefined) fields.model = args.model;
83
+ if (args.agent !== undefined) fields.agent = args.agent;
84
+ if (args.employee !== undefined) fields.employee = args.employee;
85
+ if (args.schedule_type) fields.scheduleType = args.schedule_type;
86
+
87
+ if (Object.keys(fields).length === 0)
88
+ return "Nothing to update. Pass at least one field (schedule, prompt, always, stateless, model, agent, employee, or schedule_type).";
89
+
90
+ const updated = await Job.update(args.name, fields);
91
+ if (!updated) return `Job "${args.name}" not found.`;
92
+ if (fields.prompt !== undefined) {
93
+ const job = await Job.get(args.name);
94
+ if (job) {
95
+ const resolvedPrompt = resolveJobPrompt(job);
96
+ if (resolvedPrompt.source === "file") {
97
+ return `Job "${args.name}" updated. Note: runtime prompt is still overridden by ${resolvedPrompt.filePath}.`;
98
+ }
99
+ }
100
+ }
101
+ return `Job "${args.name}" updated.`;
102
+ }
103
+
104
+ export async function removeJob(name: string): Promise<string> {
105
+ const removed = await Job.remove(name);
106
+ return removed ? `Job "${name}" removed.` : `Job "${name}" not found.`;
107
+ }
108
+
109
+ export async function enableJob(name: string): Promise<string> {
110
+ const updated = await Job.update(name, { status: "active" });
111
+ if (!updated) return `Job "${name}" not found.`;
112
+
113
+ const job = await Job.get(name);
114
+ if (job) {
115
+ const config = getConfig();
116
+ const nextRun = computeInitialNextRun(job.scheduleType, job.schedule, config.timezone);
117
+ const { getSql } = await import("../../db/connection");
118
+ await getSql()`UPDATE jobs SET next_run_at = ${nextRun} WHERE name = ${name}`;
119
+ }
120
+ return `Job "${name}" enabled.`;
121
+ }
122
+
123
+ export async function disableJob(name: string): Promise<string> {
124
+ const updated = await Job.update(name, { status: "disabled" });
125
+ return updated ? `Job "${name}" disabled.` : `Job "${name}" not found.`;
126
+ }
127
+
128
+ export async function archiveJob(name: string): Promise<string> {
129
+ const updated = await Job.update(name, { status: "archived" });
130
+ return updated ? `Job "${name}" archived.` : `Job "${name}" not found.`;
131
+ }
132
+
133
+ export async function unarchiveJob(name: string): Promise<string> {
134
+ const updated = await Job.update(name, { status: "disabled" });
135
+ return updated ? `Job "${name}" unarchived (disabled). Enable with enable_job.` : `Job "${name}" not found.`;
136
+ }
137
+
138
+ export async function runJobNow(name: string): Promise<string> {
139
+ const job = await Job.get(name);
140
+ if (!job) return `Job "${name}" not found.`;
141
+
142
+ const { getSql } = await import("../../db/connection");
143
+ await getSql()`UPDATE jobs SET next_run_at = NOW() WHERE name = ${name}`;
144
+ return `Job "${name}" queued for immediate execution.`;
145
+ }