@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/cordis.yml CHANGED
@@ -12,6 +12,8 @@
12
12
  name: './src/plugins/llm.ts'
13
13
  - id: tools
14
14
  name: './src/plugins/tools.ts'
15
+ - id: cron
16
+ name: './src/plugins/cron.ts'
15
17
  - id: mcp
16
18
  name: './src/plugins/mcp.ts'
17
19
  - id: memory
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kevin5251984/guild",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "A local guild of adventurers. npx @kevin5251984/guild web",
@@ -0,0 +1,388 @@
1
+ /** Hermes-shaped schedules plus natural language: `in 30m`, `每10分鐘`, `每天9點`. */
2
+
3
+ export const CRON_TICK_MS = 60_000;
4
+ export const CRON_MIN_EVERY_MS = 60_000;
5
+ export const CRON_JOB_CAP = 50;
6
+
7
+ export type CronKind = "once" | "every" | "cron";
8
+
9
+ export type CronSpec = {
10
+ raw: string;
11
+ kind: CronKind;
12
+ atMs?: number;
13
+ everyMs?: number;
14
+ cron?: string;
15
+ };
16
+
17
+ const UNIT_MS: Record<string, number> = {
18
+ s: 1000,
19
+ m: 60_000,
20
+ h: 3_600_000,
21
+ d: 86_400_000,
22
+ };
23
+
24
+ export function parseDurationMs(raw: string): number | null {
25
+ const text = raw.trim().toLowerCase();
26
+ const match = text.match(/^(\d+)\s*(s|m|h|d|sec|secs|second|seconds|min|mins|minute|minutes|hr|hrs|hour|hours|day|days)$/);
27
+ if (!match) return null;
28
+ const n = Number(match[1]);
29
+ if (!Number.isFinite(n) || n <= 0) return null;
30
+ const unit = match[2][0] as "s" | "m" | "h" | "d";
31
+ return n * UNIT_MS[unit];
32
+ }
33
+
34
+ function raiseEvery(ms: number): number {
35
+ return Math.max(CRON_MIN_EVERY_MS, ms);
36
+ }
37
+
38
+ export function parseCronSchedule(raw: string, now = Date.now()): CronSpec {
39
+ const text = String(raw || "").trim();
40
+ if (!text) throw new Error("schedule is required");
41
+
42
+ const iso = Date.parse(text);
43
+ if (Number.isFinite(iso) && /^\d{4}-\d{2}-\d{2}/.test(text)) {
44
+ if (iso <= now) throw new Error("one-shot time is in the past");
45
+ return { raw: text, kind: "once", atMs: iso };
46
+ }
47
+
48
+ const once = text.match(/^in\s+(.+)$/i);
49
+ if (once) {
50
+ const ms = parseDurationMs(once[1]);
51
+ if (!ms) throw new Error(`bad delay: ${once[1]}`);
52
+ return { raw: text, kind: "once", atMs: now + raiseEvery(ms) };
53
+ }
54
+
55
+ const everyWord = text.match(/^every\s+(.+)$/i);
56
+ if (everyWord) {
57
+ const rest = everyWord[1].trim().toLowerCase();
58
+ if (rest === "hour" || rest === "hours") {
59
+ return { raw: text, kind: "every", everyMs: UNIT_MS.h };
60
+ }
61
+ if (rest === "day" || rest === "days") {
62
+ return { raw: text, kind: "every", everyMs: UNIT_MS.d };
63
+ }
64
+ const ms = parseDurationMs(rest);
65
+ if (!ms) throw new Error(`bad interval: ${everyWord[1]}`);
66
+ return { raw: text, kind: "every", everyMs: raiseEvery(ms) };
67
+ }
68
+
69
+ const bare = parseDurationMs(text);
70
+ if (bare) {
71
+ return { raw: text, kind: "every", everyMs: raiseEvery(bare) };
72
+ }
73
+
74
+ if (isFiveFieldCron(text)) {
75
+ nextCronFire(text, now);
76
+ return { raw: text, kind: "cron", cron: text };
77
+ }
78
+
79
+ const natural = parseNaturalSchedule(text, now);
80
+ if (natural) return natural;
81
+
82
+ throw new Error(`bad schedule: ${text}`);
83
+ }
84
+
85
+ const ZH_UNIT: Record<string, keyof typeof UNIT_MS> = {
86
+ 秒: "s",
87
+ 秒鐘: "s",
88
+ 分: "m",
89
+ 分鐘: "m",
90
+ 小时: "h",
91
+ 小時: "h",
92
+ 天: "d",
93
+ 日: "d",
94
+ };
95
+
96
+ function zhDurationMs(n: number, unitRaw: string): number | null {
97
+ const unit = ZH_UNIT[unitRaw.replace(/^[个個]/, "")];
98
+ if (!unit || !Number.isFinite(n) || n <= 0) return null;
99
+ return n * UNIT_MS[unit];
100
+ }
101
+
102
+ function parseClock(raw: string): { hour: number; minute: number } | null {
103
+ let s = raw.trim();
104
+ if (!s) return null;
105
+ let period: "am" | "pm" | "" = "";
106
+ if (/^(早上|上午|清晨|今早)/.test(s)) {
107
+ period = "am";
108
+ s = s.replace(/^(早上|上午|清晨|今早)\s*/, "");
109
+ } else if (/^(晚上|傍晚|今晚|下午)/.test(s)) {
110
+ period = "pm";
111
+ s = s.replace(/^(晚上|傍晚|今晚|下午)\s*/, "");
112
+ } else if (/^(中午)/.test(s)) {
113
+ s = s.replace(/^(中午)\s*/, "");
114
+ if (!s) return { hour: 12, minute: 0 };
115
+ period = "pm";
116
+ } else if (/^(凌晨|午夜)/.test(s)) {
117
+ period = "am";
118
+ s = s.replace(/^(凌晨|午夜)\s*/, "");
119
+ if (!s) return { hour: 0, minute: 0 };
120
+ }
121
+ const enMeridiem = s.match(/^(.*?)(?:\s*)(am|pm)$/i);
122
+ if (enMeridiem) {
123
+ period = enMeridiem[2].toLowerCase() as "am" | "pm";
124
+ s = enMeridiem[1].trim();
125
+ }
126
+ let hour = -1;
127
+ let minute = 0;
128
+ const half = s.match(/^(\d{1,2})\s*[點点]半$/);
129
+ const zh = s.match(/^(\d{1,2})\s*[點点](?:\s*(\d{1,2})\s*分?)?$/);
130
+ const colon = s.match(/^(\d{1,2})[::](\d{2})$/);
131
+ const hourOnly = s.match(/^(\d{1,2})$/);
132
+ if (half) {
133
+ hour = Number(half[1]);
134
+ minute = 30;
135
+ } else if (zh) {
136
+ hour = Number(zh[1]);
137
+ minute = zh[2] ? Number(zh[2]) : 0;
138
+ } else if (colon) {
139
+ hour = Number(colon[1]);
140
+ minute = Number(colon[2]);
141
+ } else if (hourOnly) {
142
+ hour = Number(hourOnly[1]);
143
+ minute = 0;
144
+ } else {
145
+ return null;
146
+ }
147
+ if (!Number.isFinite(hour) || !Number.isFinite(minute)) return null;
148
+ if (minute < 0 || minute > 59 || hour < 0 || hour > 24) return null;
149
+ if (hour === 24) {
150
+ if (minute !== 0) return null;
151
+ hour = 0;
152
+ }
153
+ if (period === "am") {
154
+ if (hour === 12) hour = 0;
155
+ else if (hour > 12) return null;
156
+ } else if (period === "pm") {
157
+ if (hour < 12) hour += 12;
158
+ }
159
+ if (hour > 23) return null;
160
+ return { hour, minute };
161
+ }
162
+
163
+ function atLocal(
164
+ fromMs: number,
165
+ hour: number,
166
+ minute: number,
167
+ dayOffset: number,
168
+ ): number {
169
+ const d = new Date(fromMs);
170
+ d.setSeconds(0, 0);
171
+ d.setDate(d.getDate() + dayOffset);
172
+ d.setHours(hour, minute, 0, 0);
173
+ return d.getTime();
174
+ }
175
+
176
+ /** Chinese / leftover English phrases the model may pass through as schedule. */
177
+ export function parseNaturalSchedule(text: string, now: number): CronSpec | null {
178
+ const folded = text.trim().replace(/\s+/g, " ");
179
+ if (!folded) return null;
180
+
181
+ const everyZh = folded.match(
182
+ /^每(?:隔)?\s*(\d+)\s*(秒鐘?|分鐘?|个?小时|個?小時|[天日])$/,
183
+ );
184
+ if (everyZh) {
185
+ const ms = zhDurationMs(Number(everyZh[1]), everyZh[2]);
186
+ if (ms) return { raw: text, kind: "every", everyMs: raiseEvery(ms) };
187
+ }
188
+ if (/^每(?:個|个)?(?:小時|小时)$/.test(folded)) {
189
+ return { raw: text, kind: "every", everyMs: UNIT_MS.h };
190
+ }
191
+ if (/^每(?:一)?天$/.test(folded) || folded === "每日") {
192
+ return { raw: text, kind: "every", everyMs: UNIT_MS.d };
193
+ }
194
+
195
+ const laterZh = folded.match(
196
+ /^(?:再|過|过)?\s*(\d+)\s*(秒鐘?|分鐘?|个?小时|個?小時|[天日])\s*(?:後|后|以後|以后|之後|之后)$/,
197
+ );
198
+ if (laterZh) {
199
+ const ms = zhDurationMs(Number(laterZh[1]), laterZh[2]);
200
+ if (ms) return { raw: text, kind: "once", atMs: now + raiseEvery(ms) };
201
+ }
202
+
203
+ const daily = folded.match(/^(?:每天|每日)\s*(.+)$/);
204
+ if (daily) {
205
+ const clock = parseClock(daily[1]);
206
+ if (clock) {
207
+ return {
208
+ raw: text,
209
+ kind: "cron",
210
+ cron: `${clock.minute} ${clock.hour} * * *`,
211
+ };
212
+ }
213
+ }
214
+
215
+ const tomorrow = folded.match(/^(?:明天|明日)\s*(.+)$/);
216
+ if (tomorrow) {
217
+ const clock = parseClock(tomorrow[1]);
218
+ if (clock) {
219
+ const at = atLocal(now, clock.hour, clock.minute, 1);
220
+ if (at <= now) throw new Error("one-shot time is in the past");
221
+ return { raw: text, kind: "once", atMs: at };
222
+ }
223
+ }
224
+
225
+ const enTomorrow = folded.match(/^tomorrow\s+(.+)$/i);
226
+ if (enTomorrow) {
227
+ const clock = parseClock(enTomorrow[1]);
228
+ if (clock) {
229
+ const at = atLocal(now, clock.hour, clock.minute, 1);
230
+ if (at <= now) throw new Error("one-shot time is in the past");
231
+ return { raw: text, kind: "once", atMs: at };
232
+ }
233
+ }
234
+
235
+ const dailyEn = folded.match(/^(?:daily|every day)\s+(?:at\s+)?(.+)$/i);
236
+ if (dailyEn) {
237
+ const clock = parseClock(dailyEn[1]);
238
+ if (clock) {
239
+ return {
240
+ raw: text,
241
+ kind: "cron",
242
+ cron: `${clock.minute} ${clock.hour} * * *`,
243
+ };
244
+ }
245
+ }
246
+
247
+ return null;
248
+ }
249
+
250
+ export function nextRunAt(spec: CronSpec, fromMs = Date.now()): number {
251
+ if (spec.kind === "once") {
252
+ const at = spec.atMs ?? fromMs;
253
+ return at;
254
+ }
255
+ if (spec.kind === "every") {
256
+ return fromMs + (spec.everyMs ?? CRON_MIN_EVERY_MS);
257
+ }
258
+ return nextCronFire(spec.cron || spec.raw, fromMs);
259
+ }
260
+
261
+ export function followingRun(spec: CronSpec, fromMs = Date.now()): number | null {
262
+ if (spec.kind === "once") return null;
263
+ if (spec.kind === "every") return fromMs + (spec.everyMs ?? CRON_MIN_EVERY_MS);
264
+ return nextCronFire(spec.cron || spec.raw, fromMs);
265
+ }
266
+
267
+ function isFiveFieldCron(text: string): boolean {
268
+ const parts = text.split(/\s+/);
269
+ return parts.length === 5 && parts.every((part) => /^[\d*,\-\/]+$/.test(part));
270
+ }
271
+
272
+ type CronFields = {
273
+ minute: Set<number>;
274
+ hour: Set<number>;
275
+ day: Set<number>;
276
+ month: Set<number>;
277
+ weekday: Set<number>;
278
+ };
279
+
280
+ function parseCronField(raw: string, min: number, max: number): Set<number> {
281
+ const out = new Set<number>();
282
+ for (const chunk of raw.split(",")) {
283
+ const [range, stepRaw] = chunk.split("/");
284
+ const step = stepRaw ? Number(stepRaw) : 1;
285
+ if (!Number.isFinite(step) || step < 1) {
286
+ throw new Error(`bad cron field: ${raw}`);
287
+ }
288
+ let start = min;
289
+ let end = max;
290
+ if (range !== "*") {
291
+ if (range.includes("-")) {
292
+ const [a, b] = range.split("-").map(Number);
293
+ start = a;
294
+ end = b;
295
+ } else {
296
+ start = Number(range);
297
+ end = start;
298
+ }
299
+ }
300
+ if (
301
+ !Number.isFinite(start) ||
302
+ !Number.isFinite(end) ||
303
+ start < min ||
304
+ end > max ||
305
+ start > end
306
+ ) {
307
+ throw new Error(`bad cron field: ${raw}`);
308
+ }
309
+ for (let i = start; i <= end; i += step) out.add(i);
310
+ }
311
+ if (!out.size) throw new Error(`bad cron field: ${raw}`);
312
+ return out;
313
+ }
314
+
315
+ function parseFive(expr: string): CronFields {
316
+ const parts = expr.trim().split(/\s+/);
317
+ if (parts.length !== 5) throw new Error("cron needs 5 fields");
318
+ return {
319
+ minute: parseCronField(parts[0], 0, 59),
320
+ hour: parseCronField(parts[1], 0, 23),
321
+ day: parseCronField(parts[2], 1, 31),
322
+ month: parseCronField(parts[3], 1, 12),
323
+ weekday: parseWeekdayField(parts[4]),
324
+ };
325
+ }
326
+
327
+ function parseWeekdayField(raw: string): Set<number> {
328
+ const values = parseCronField(raw, 0, 7);
329
+ const out = new Set<number>();
330
+ for (const value of values) out.add(value === 7 ? 0 : value);
331
+ return out;
332
+ }
333
+
334
+ function matchCron(fields: CronFields, date: Date): boolean {
335
+ if (
336
+ !fields.minute.has(date.getMinutes()) ||
337
+ !fields.hour.has(date.getHours()) ||
338
+ !fields.month.has(date.getMonth() + 1)
339
+ ) {
340
+ return false;
341
+ }
342
+ const dayOk = fields.day.has(date.getDate());
343
+ const weekOk = fields.weekday.has(date.getDay());
344
+ const dayRestricted = fields.day.size < 31;
345
+ const weekRestricted = fields.weekday.size < 7;
346
+ return dayRestricted && weekRestricted ? dayOk || weekOk : dayOk && weekOk;
347
+ }
348
+
349
+ export function nextCronFire(expr: string, fromMs: number): number {
350
+ const fields = parseFive(expr);
351
+ const start = new Date(fromMs);
352
+ start.setSeconds(0, 0);
353
+ start.setMinutes(start.getMinutes() + 1);
354
+ const limit = fromMs + 366 * 24 * 60 * 60 * 1000;
355
+ for (let t = start.getTime(); t <= limit; t += 60_000) {
356
+ if (matchCron(fields, new Date(t))) return t;
357
+ }
358
+ throw new Error("cron expression never fires");
359
+ }
360
+
361
+ export function tokenizeCronSlash(text: string): string[] {
362
+ const out: string[] = [];
363
+ const src = text.trim();
364
+ let i = 0;
365
+ while (i < src.length) {
366
+ while (src[i] === " ") i += 1;
367
+ if (i >= src.length) break;
368
+ if (src[i] === '"' || src[i] === "'") {
369
+ const q = src[i];
370
+ i += 1;
371
+ let buf = "";
372
+ while (i < src.length && src[i] !== q) {
373
+ buf += src[i];
374
+ i += 1;
375
+ }
376
+ if (src[i] === q) i += 1;
377
+ out.push(buf);
378
+ continue;
379
+ }
380
+ let buf = "";
381
+ while (i < src.length && src[i] !== " ") {
382
+ buf += src[i];
383
+ i += 1;
384
+ }
385
+ out.push(buf);
386
+ }
387
+ return out;
388
+ }