@kevin5251984/guild 0.2.20 → 0.2.22

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/src/cron.ts ADDED
@@ -0,0 +1,403 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { CronJobRow } from "./db.ts";
3
+ import type { HandlerExtras } from "./handlers.ts";
4
+ import { StoreError, type GuildStore } from "./store.ts";
5
+ import type { ToolContext, ToolOutcome } from "./tools.ts";
6
+ import {
7
+ CRON_JOB_CAP,
8
+ CRON_MIN_EVERY_MS,
9
+ followingRun,
10
+ nextRunAt,
11
+ parseCronSchedule,
12
+ tokenizeCronSlash,
13
+ type CronSpec,
14
+ } from "./cron-schedule.ts";
15
+
16
+ const inflight = new Set<string>();
17
+
18
+ function specOf(job: CronJobRow): CronSpec {
19
+ return {
20
+ raw: job.schedule,
21
+ kind: job.kind,
22
+ atMs: job.atMs,
23
+ everyMs: job.everyMs,
24
+ cron: job.cronExpr,
25
+ };
26
+ }
27
+
28
+ export function publicCronJob(job: CronJobRow) {
29
+ return {
30
+ id: job.id,
31
+ name: job.name,
32
+ roomId: job.roomId,
33
+ botId: job.botId,
34
+ prompt: job.prompt,
35
+ schedule: job.schedule,
36
+ kind: job.kind,
37
+ nextRunAt: job.nextRunAt,
38
+ paused: job.paused,
39
+ createdAt: job.createdAt,
40
+ lastRunAt: job.lastRunAt,
41
+ lastStatus: job.lastStatus,
42
+ lastError: job.lastError,
43
+ running: inflight.has(job.id),
44
+ };
45
+ }
46
+
47
+ export function createCronJob(
48
+ store: GuildStore,
49
+ input: {
50
+ roomId: string;
51
+ botId: string;
52
+ prompt: string;
53
+ schedule: string;
54
+ name?: string;
55
+ },
56
+ ): CronJobRow {
57
+ const room = store.getRoom(input.roomId);
58
+ if (!room) throw new StoreError(404, "room not found");
59
+ const bot = store.getBot(input.botId);
60
+ if (!bot) throw new StoreError(400, "bot not found");
61
+ if (room.kind === "channel" && !room.memberIds.includes(bot.id)) {
62
+ throw new StoreError(400, "bot is not on this quest");
63
+ }
64
+ const prompt = input.prompt.trim();
65
+ if (!prompt) throw new StoreError(400, "prompt is required");
66
+ if (store.listCronJobs().length >= CRON_JOB_CAP) {
67
+ throw new StoreError(400, `at most ${CRON_JOB_CAP} cron jobs`);
68
+ }
69
+ let spec;
70
+ try {
71
+ spec = parseCronSchedule(input.schedule);
72
+ } catch (error) {
73
+ throw new StoreError(
74
+ 400,
75
+ error instanceof Error ? error.message : String(error),
76
+ );
77
+ }
78
+ const now = Date.now();
79
+ const name =
80
+ (input.name || "").trim() ||
81
+ prompt.replace(/\s+/g, " ").trim().slice(0, 28) ||
82
+ "cron";
83
+ const job: CronJobRow = {
84
+ id: randomUUID(),
85
+ name,
86
+ roomId: room.id,
87
+ botId: bot.id,
88
+ prompt,
89
+ schedule: spec.raw,
90
+ kind: spec.kind,
91
+ everyMs: spec.everyMs,
92
+ cronExpr: spec.cron,
93
+ atMs: spec.atMs,
94
+ nextRunAt: new Date(nextRunAt(spec, now)).toISOString(),
95
+ paused: false,
96
+ createdAt: new Date(now).toISOString(),
97
+ };
98
+ store.writeCronJob(job);
99
+ return job;
100
+ }
101
+
102
+ export function pauseCronJob(store: GuildStore, id: string): CronJobRow {
103
+ const job = store.getCronJob(id);
104
+ job.paused = true;
105
+ store.writeCronJob(job);
106
+ return job;
107
+ }
108
+
109
+ export function resumeCronJob(store: GuildStore, id: string): CronJobRow {
110
+ const job = store.getCronJob(id);
111
+ job.paused = false;
112
+ job.nextRunAt = new Date(nextRunAt(specOf(job), Date.now())).toISOString();
113
+ store.writeCronJob(job);
114
+ return job;
115
+ }
116
+
117
+ export function removeCronJob(store: GuildStore, id: string): { ok: true; id: string } {
118
+ if (!store.deleteCronJob(id)) throw new StoreError(404, "cron job not found");
119
+ return { ok: true, id };
120
+ }
121
+
122
+ function readCronJob(store: GuildStore, id: string): CronJobRow | null {
123
+ try {
124
+ return store.getCronJob(id);
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function commitFiredJob(
131
+ store: GuildStore,
132
+ id: string,
133
+ patch: Partial<CronJobRow> | "delete",
134
+ ): void {
135
+ const current = readCronJob(store, id);
136
+ if (!current) return;
137
+ if (patch === "delete") {
138
+ store.deleteCronJob(id);
139
+ return;
140
+ }
141
+ store.writeCronJob({
142
+ ...current,
143
+ ...patch,
144
+ id: current.id,
145
+ roomId: current.roomId,
146
+ botId: current.botId,
147
+ paused: current.paused || Boolean(patch.paused),
148
+ });
149
+ }
150
+
151
+ function seatBlocked(store: GuildStore, roomId: string, botId: string): string | null {
152
+ const live = store.listLiveRoomTurns(roomId);
153
+ if (live.some((turn) => turn.botId === botId)) return "seat busy";
154
+ if (live.some((turn) => !turn.paused)) return "room busy";
155
+ return null;
156
+ }
157
+
158
+ function findCronJob(store: GuildStore, ref: string): CronJobRow {
159
+ const id = ref.trim();
160
+ if (!id) throw new StoreError(400, "job id is required");
161
+ const direct = store.listCronJobs().find((job) => job.id === id);
162
+ if (direct) return direct;
163
+ const needle = id.toLowerCase();
164
+ const named = store.listCronJobs().filter((job) => job.name.toLowerCase() === needle);
165
+ if (named.length === 1) return named[0];
166
+ if (named.length > 1) {
167
+ throw new StoreError(
168
+ 400,
169
+ `name matches ${named.length} jobs: ${named.map((job) => job.id).join(", ")}`,
170
+ );
171
+ }
172
+ throw new StoreError(404, "cron job not found");
173
+ }
174
+
175
+ export async function fireCronJob(
176
+ store: GuildStore,
177
+ id: string,
178
+ env: NodeJS.ProcessEnv = process.env,
179
+ extras: HandlerExtras = {},
180
+ ): Promise<{ ok: boolean; skipped?: string; error?: string }> {
181
+ const job = store.getCronJob(id);
182
+ if (job.paused) return { ok: false, skipped: "paused" };
183
+ if (inflight.has(job.id)) return { ok: false, skipped: "already running" };
184
+ const blocked = seatBlocked(store, job.roomId, job.botId);
185
+ if (blocked) return { ok: false, skipped: blocked };
186
+ const room = store.getRoom(job.roomId);
187
+ if (!room) {
188
+ commitFiredJob(store, job.id, {
189
+ paused: true,
190
+ lastStatus: "failed",
191
+ lastError: "room not found",
192
+ });
193
+ return { ok: false, error: "room not found" };
194
+ }
195
+ if (room.kind === "channel" && !room.memberIds.includes(job.botId)) {
196
+ commitFiredJob(store, job.id, {
197
+ paused: true,
198
+ lastStatus: "failed",
199
+ lastError: "bot is not on this quest",
200
+ });
201
+ return { ok: false, error: "bot is not on this quest" };
202
+ }
203
+ const bot = store.getBot(job.botId);
204
+ if (!bot) {
205
+ commitFiredJob(store, job.id, {
206
+ paused: true,
207
+ lastStatus: "failed",
208
+ lastError: "bot not found",
209
+ });
210
+ return { ok: false, error: "bot not found" };
211
+ }
212
+ inflight.add(job.id);
213
+ const now = Date.now();
214
+ const failPatch = (message: string, pauseOnce: boolean): Partial<CronJobRow> => {
215
+ const next = followingRun(specOf(job), now);
216
+ return {
217
+ lastRunAt: new Date(now).toISOString(),
218
+ lastStatus: "failed",
219
+ lastError: message.slice(0, 400),
220
+ paused: pauseOnce && job.kind === "once",
221
+ nextRunAt: new Date(
222
+ next ?? now + CRON_MIN_EVERY_MS,
223
+ ).toISOString(),
224
+ };
225
+ };
226
+ try {
227
+ const { postUserMessage } = await import("./handlers.ts");
228
+ const body = `排程 · ${job.name}\n\n@${bot.handle} ${job.prompt}`;
229
+ const posted = await postUserMessage(
230
+ store,
231
+ job.roomId,
232
+ body,
233
+ env,
234
+ undefined,
235
+ undefined,
236
+ job.botId,
237
+ { ...extras, mentions: [job.botId], harvest: false, cronRun: true },
238
+ );
239
+ const spoke = (posted.replies || []).some((msg) => msg.author === job.botId);
240
+ if (!spoke) {
241
+ commitFiredJob(store, job.id, failPatch("no reply", true));
242
+ return { ok: false, skipped: "no reply" };
243
+ }
244
+ const next = followingRun(specOf(job), now);
245
+ if (next == null) {
246
+ commitFiredJob(store, job.id, "delete");
247
+ } else {
248
+ commitFiredJob(store, job.id, {
249
+ lastRunAt: new Date(now).toISOString(),
250
+ lastStatus: "ok",
251
+ lastError: undefined,
252
+ nextRunAt: new Date(next).toISOString(),
253
+ });
254
+ }
255
+ return { ok: true };
256
+ } catch (error) {
257
+ const message = error instanceof Error ? error.message : String(error);
258
+ commitFiredJob(store, job.id, failPatch(message, true));
259
+ return { ok: false, error: message };
260
+ } finally {
261
+ inflight.delete(job.id);
262
+ }
263
+ }
264
+
265
+ export async function tickCronJobs(
266
+ store: GuildStore,
267
+ env: NodeJS.ProcessEnv = process.env,
268
+ now = Date.now(),
269
+ ): Promise<void> {
270
+ const due = store
271
+ .listCronJobs()
272
+ .filter((job) => !job.paused && Date.parse(job.nextRunAt) <= now);
273
+ for (const job of due) {
274
+ if (inflight.has(job.id)) continue;
275
+ try {
276
+ await fireCronJob(store, job.id, env);
277
+ } catch {
278
+ /* one job must not block the tick */
279
+ }
280
+ }
281
+ }
282
+
283
+ export function executeCronjob(
284
+ store: GuildStore,
285
+ args: Record<string, unknown>,
286
+ ctx: ToolContext = {},
287
+ ): Promise<ToolOutcome> | ToolOutcome {
288
+ if (ctx.cronRun) {
289
+ return {
290
+ text: "cron jobs cannot manage cron (Hermes: no recursive scheduling)",
291
+ isError: true,
292
+ };
293
+ }
294
+ const action = String(args.action || args.op || "list").trim().toLowerCase();
295
+ try {
296
+ if (action === "list") {
297
+ const roomId =
298
+ typeof args.room_id === "string" ? args.room_id : ctx.roomId;
299
+ const jobs = store
300
+ .listCronJobs(roomId || undefined)
301
+ .map(publicCronJob);
302
+ return {
303
+ text: jobs.length ? JSON.stringify(jobs, null, 2) : "(no cron jobs)",
304
+ isError: false,
305
+ };
306
+ }
307
+ if (action === "create") {
308
+ const schedule = String(args.schedule || "").trim();
309
+ const prompt = String(args.prompt || args.task || "").trim();
310
+ const roomId =
311
+ (typeof args.room_id === "string" && args.room_id) || ctx.roomId || "";
312
+ const botId =
313
+ (typeof args.bot_id === "string" && args.bot_id) ||
314
+ (typeof args.botId === "string" && args.botId) ||
315
+ ctx.botId ||
316
+ "";
317
+ if (!roomId) throw new StoreError(400, "room_id is required");
318
+ if (!botId) throw new StoreError(400, "bot_id is required");
319
+ const job = createCronJob(store, {
320
+ roomId,
321
+ botId,
322
+ prompt,
323
+ schedule,
324
+ name: typeof args.name === "string" ? args.name : "",
325
+ });
326
+ return {
327
+ text: `created ${job.id} · ${job.name} · next ${job.nextRunAt} · ${job.schedule}`,
328
+ isError: false,
329
+ };
330
+ }
331
+ const ref = String(args.job_id || args.id || args.name || "").trim();
332
+ if (action === "pause") {
333
+ const job = pauseCronJob(store, findCronJob(store, ref).id);
334
+ return { text: `paused ${job.id} · ${job.name}`, isError: false };
335
+ }
336
+ if (action === "resume") {
337
+ const job = resumeCronJob(store, findCronJob(store, ref).id);
338
+ return {
339
+ text: `resumed ${job.id} · next ${job.nextRunAt}`,
340
+ isError: false,
341
+ };
342
+ }
343
+ if (action === "remove" || action === "delete") {
344
+ const job = findCronJob(store, ref);
345
+ removeCronJob(store, job.id);
346
+ return { text: `removed ${job.id} · ${job.name}`, isError: false };
347
+ }
348
+ if (action === "run") {
349
+ const job = findCronJob(store, ref);
350
+ return fireCronJob(store, job.id, ctx.env).then((result) => ({
351
+ text: result.ok
352
+ ? `ran ${job.id}`
353
+ : result.skipped
354
+ ? `skipped ${job.id}: ${result.skipped}`
355
+ : `failed ${job.id}: ${result.error || "error"}`,
356
+ isError: !result.ok && !result.skipped,
357
+ }));
358
+ }
359
+ return { text: `unknown cron action: ${action}`, isError: true };
360
+ } catch (error) {
361
+ const message = error instanceof Error ? error.message : String(error);
362
+ return { text: message, isError: true };
363
+ }
364
+ }
365
+
366
+ export function runCronSlash(
367
+ store: GuildStore,
368
+ roomId: string,
369
+ botId: string,
370
+ text: string,
371
+ ): { text: string; isError: boolean } {
372
+ const tokens = tokenizeCronSlash(text.replace(/^\/cron\b/i, "").trim());
373
+ const action = (tokens[0] || "list").toLowerCase();
374
+ if (action === "list" || action === "") {
375
+ return executeCronjob(store, { action: "list", room_id: roomId }, { roomId });
376
+ }
377
+ if (action === "add" || action === "create") {
378
+ const schedule = tokens[1] || "";
379
+ const prompt = tokens.slice(2).join(" ");
380
+ return executeCronjob(
381
+ store,
382
+ { action: "create", schedule, prompt, room_id: roomId, bot_id: botId },
383
+ { roomId },
384
+ );
385
+ }
386
+ if (
387
+ action === "pause" ||
388
+ action === "resume" ||
389
+ action === "run" ||
390
+ action === "remove" ||
391
+ action === "delete"
392
+ ) {
393
+ return executeCronjob(
394
+ store,
395
+ { action, job_id: tokens[1] || "" },
396
+ { roomId },
397
+ );
398
+ }
399
+ return {
400
+ text: 'usage: /cron list | add "<schedule>" "<prompt>" | pause|resume|run|remove <id>',
401
+ isError: true,
402
+ };
403
+ }
package/src/db.ts CHANGED
@@ -97,6 +97,27 @@ CREATE TABLE IF NOT EXISTS compact (
97
97
  updated_at TEXT NOT NULL,
98
98
  message_count INTEGER NOT NULL DEFAULT 0
99
99
  );
100
+
101
+ CREATE TABLE IF NOT EXISTS cron_jobs (
102
+ id TEXT PRIMARY KEY,
103
+ name TEXT NOT NULL,
104
+ room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
105
+ bot_id TEXT NOT NULL,
106
+ prompt TEXT NOT NULL,
107
+ schedule TEXT NOT NULL,
108
+ kind TEXT NOT NULL CHECK (kind IN ('once', 'every', 'cron')),
109
+ every_ms INTEGER,
110
+ cron_expr TEXT,
111
+ at_ms INTEGER,
112
+ next_run_at TEXT NOT NULL,
113
+ paused INTEGER NOT NULL DEFAULT 0,
114
+ created_at TEXT NOT NULL,
115
+ last_run_at TEXT,
116
+ last_status TEXT,
117
+ last_error TEXT
118
+ );
119
+
120
+ CREATE INDEX IF NOT EXISTS cron_jobs_next ON cron_jobs(paused, next_run_at);
100
121
  `;
101
122
 
102
123
  type CompactRow = {
@@ -106,6 +127,54 @@ type CompactRow = {
106
127
  messageCount: number;
107
128
  };
108
129
 
130
+ export type CronJobRow = {
131
+ id: string;
132
+ name: string;
133
+ roomId: string;
134
+ botId: string;
135
+ prompt: string;
136
+ schedule: string;
137
+ kind: "once" | "every" | "cron";
138
+ everyMs?: number;
139
+ cronExpr?: string;
140
+ atMs?: number;
141
+ nextRunAt: string;
142
+ paused: boolean;
143
+ createdAt: string;
144
+ lastRunAt?: string;
145
+ lastStatus?: string;
146
+ lastError?: string;
147
+ };
148
+
149
+ function cronJobFromRow(row: Record<string, unknown>): CronJobRow {
150
+ const job: CronJobRow = {
151
+ id: asString(row.id),
152
+ name: asString(row.name),
153
+ roomId: asString(row.room_id),
154
+ botId: asString(row.bot_id),
155
+ prompt: asString(row.prompt),
156
+ schedule: asString(row.schedule),
157
+ kind:
158
+ asString(row.kind) === "once" || asString(row.kind) === "cron"
159
+ ? asString(row.kind)
160
+ : "every",
161
+ nextRunAt: asString(row.next_run_at),
162
+ paused: asNumber(row.paused) === 1,
163
+ createdAt: asString(row.created_at),
164
+ };
165
+ if (row.every_ms != null) job.everyMs = asNumber(row.every_ms);
166
+ const cronExpr = asString(row.cron_expr);
167
+ if (cronExpr) job.cronExpr = cronExpr;
168
+ if (row.at_ms != null) job.atMs = asNumber(row.at_ms);
169
+ const lastRun = asString(row.last_run_at);
170
+ if (lastRun) job.lastRunAt = lastRun;
171
+ const lastStatus = asString(row.last_status);
172
+ if (lastStatus) job.lastStatus = lastStatus;
173
+ const lastError = asString(row.last_error);
174
+ if (lastError) job.lastError = lastError;
175
+ return job;
176
+ }
177
+
109
178
  function asString(value: unknown, fallback = ""): string {
110
179
  return typeof value === "string" ? value : fallback;
111
180
  }
@@ -771,6 +840,72 @@ export class GuildDb {
771
840
  }
772
841
  }
773
842
 
843
+ listCronJobs(roomId?: string): CronJobRow[] {
844
+ const rows = roomId
845
+ ? (this.sqlite
846
+ .prepare("SELECT * FROM cron_jobs WHERE room_id = ? ORDER BY created_at")
847
+ .all(roomId) as Record<string, unknown>[])
848
+ : (this.sqlite
849
+ .prepare("SELECT * FROM cron_jobs ORDER BY next_run_at")
850
+ .all() as Record<string, unknown>[]);
851
+ return rows.map(cronJobFromRow);
852
+ }
853
+
854
+ getCronJob(id: string): CronJobRow | null {
855
+ const row = this.sqlite
856
+ .prepare("SELECT * FROM cron_jobs WHERE id = ?")
857
+ .get(id) as Record<string, unknown> | undefined;
858
+ return row ? cronJobFromRow(row) : null;
859
+ }
860
+
861
+ upsertCronJob(job: CronJobRow): void {
862
+ this.sqlite
863
+ .prepare(
864
+ `INSERT INTO cron_jobs (
865
+ id, name, room_id, bot_id, prompt, schedule, kind, every_ms, cron_expr, at_ms,
866
+ next_run_at, paused, created_at, last_run_at, last_status, last_error
867
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
868
+ ON CONFLICT(id) DO UPDATE SET
869
+ name = excluded.name,
870
+ room_id = excluded.room_id,
871
+ bot_id = excluded.bot_id,
872
+ prompt = excluded.prompt,
873
+ schedule = excluded.schedule,
874
+ kind = excluded.kind,
875
+ every_ms = excluded.every_ms,
876
+ cron_expr = excluded.cron_expr,
877
+ at_ms = excluded.at_ms,
878
+ next_run_at = excluded.next_run_at,
879
+ paused = excluded.paused,
880
+ last_run_at = excluded.last_run_at,
881
+ last_status = excluded.last_status,
882
+ last_error = excluded.last_error`,
883
+ )
884
+ .run(
885
+ job.id,
886
+ job.name,
887
+ job.roomId,
888
+ job.botId,
889
+ job.prompt,
890
+ job.schedule,
891
+ job.kind,
892
+ job.everyMs ?? null,
893
+ job.cronExpr ?? null,
894
+ job.atMs ?? null,
895
+ job.nextRunAt,
896
+ job.paused ? 1 : 0,
897
+ job.createdAt,
898
+ job.lastRunAt ?? null,
899
+ job.lastStatus ?? null,
900
+ job.lastError ?? null,
901
+ );
902
+ }
903
+
904
+ deleteCronJob(id: string): boolean {
905
+ const result = this.sqlite.prepare("DELETE FROM cron_jobs WHERE id = ?").run(id);
906
+ return result.changes > 0;
907
+ }
908
+
774
909
  private messageCount(roomId: string): number {
775
910
  const row = this.sqlite
776
911
  .prepare("SELECT COUNT(*) AS n FROM messages WHERE room_id = ?")
package/src/generate.ts CHANGED
@@ -504,6 +504,9 @@ export async function chatReply(input: {
504
504
  mcpTools?: McpToolRef[];
505
505
  sandbox?: Sandbox;
506
506
  workspace?: string;
507
+ roomId?: string;
508
+ botId?: string;
509
+ cronRun?: boolean;
507
510
  dispatch?: ToolContext["dispatch"];
508
511
  }): Promise<ChatReply> {
509
512
  const env = input.env ?? process.env;
@@ -569,6 +572,9 @@ async function tryChatLlm(
569
572
  mcpTools?: McpToolRef[];
570
573
  sandbox?: Sandbox;
571
574
  workspace?: string;
575
+ roomId?: string;
576
+ botId?: string;
577
+ cronRun?: boolean;
572
578
  dispatch?: ToolContext["dispatch"];
573
579
  },
574
580
  env: NodeJS.ProcessEnv,
@@ -625,6 +631,9 @@ async function tryChatLlm(
625
631
  signal: input.signal,
626
632
  mcpTools: input.mcpTools ?? (await listMcpToolRefs(dataDir)),
627
633
  spawnHandles: new Map(),
634
+ ...(input.roomId ? { roomId: input.roomId } : {}),
635
+ ...(input.botId ? { botId: input.botId } : {}),
636
+ ...(input.cronRun ? { cronRun: true } : {}),
628
637
  ...policyFor(env, {
629
638
  sandbox: input.sandbox,
630
639
  workspace: input.workspace,