@chloejs/core 0.2.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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +221 -0
  3. package/channels/api.ts +41 -0
  4. package/channels/shared.ts +250 -0
  5. package/channels/slack.ts +390 -0
  6. package/channels/telegram.ts +396 -0
  7. package/core/clock.ts +126 -0
  8. package/core/confine.ts +45 -0
  9. package/core/db.ts +117 -0
  10. package/core/markdown.ts +95 -0
  11. package/core/notes.ts +44 -0
  12. package/core/paths.ts +29 -0
  13. package/core/root.ts +26 -0
  14. package/core/settings.ts +124 -0
  15. package/core/steps.ts +896 -0
  16. package/core/turn.ts +314 -0
  17. package/do/email.ts +45 -0
  18. package/do/files.ts +96 -0
  19. package/do/mail.ts +155 -0
  20. package/do/run.ts +56 -0
  21. package/do/scripts.ts +49 -0
  22. package/do/web.ts +192 -0
  23. package/index.ts +52 -0
  24. package/load/job.ts +84 -0
  25. package/load/load.ts +478 -0
  26. package/model/ask.ts +84 -0
  27. package/model/claude.ts +261 -0
  28. package/model/memory.ts +68 -0
  29. package/model/model.ts +185 -0
  30. package/model/tool.ts +53 -0
  31. package/model/tools/files.ts +71 -0
  32. package/model/tools/gmail.ts +43 -0
  33. package/model/tools/index.ts +28 -0
  34. package/model/tools/memory.ts +23 -0
  35. package/model/tools/run_script.ts +44 -0
  36. package/model/tools/send_email.ts +29 -0
  37. package/model/tools/web.ts +23 -0
  38. package/model/tools/write_skill.ts +31 -0
  39. package/ops/account.ts +109 -0
  40. package/ops/agent.ts +290 -0
  41. package/ops/check.ts +37 -0
  42. package/ops/evals.ts +206 -0
  43. package/ops/install.sh +101 -0
  44. package/ops/test.ts +1976 -0
  45. package/package.json +65 -0
  46. package/scorers/calls.ts +50 -0
  47. package/scorers/expectations.ts +118 -0
  48. package/scorers/index.ts +5 -0
  49. package/serve/alerts.ts +79 -0
  50. package/serve/errors.ts +10 -0
  51. package/serve/files.ts +70 -0
  52. package/serve/http.ts +767 -0
  53. package/serve/login.ts +299 -0
  54. package/serve/memory.ts +372 -0
  55. package/serve/page.ts +142 -0
  56. package/serve/pass.ts +45 -0
  57. package/serve/recentWork.ts +69 -0
  58. package/serve/site.ts +409 -0
  59. package/serve/tokens.ts +132 -0
  60. package/server.ts +170 -0
  61. package/timer/cron.ts +92 -0
  62. package/timer/every.ts +153 -0
  63. package/timer/index.ts +4 -0
@@ -0,0 +1,132 @@
1
+ // Tokens for other systems, made and revoked from the runtime site.
2
+ //
3
+ // A token is not a second password. It opens the read side of the API and the
4
+ // agents that bind an api channel, and nothing else: it cannot make or revoke
5
+ // tokens, cannot write a file, and cannot reach an agent that has not opted in.
6
+ // The account session is the one that can do everything, and it is only ever
7
+ // held by a browser somebody signed in on.
8
+ //
9
+ // The token itself is shown once, when it is made, and never stored. What is
10
+ // kept is its sha256, so this file leaking is not the same as the tokens
11
+ // leaking. Losing one means revoking it and making another.
12
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import crypto from "node:crypto";
14
+
15
+ import { STATE } from "#chloe/core/paths.ts";
16
+
17
+ /** Beside the account and the run history, mode 600. Not in source control. */
18
+ const FILE = `${STATE}/tokens.json`;
19
+
20
+ /** Long enough that guessing is not a strategy. */
21
+ const BYTES = 32;
22
+
23
+ /** So a token is recognisable in a log or a config file as something to rotate. */
24
+ const PREFIX = "chloe_";
25
+
26
+ export interface Token {
27
+ id: string;
28
+ /** What it is for, so revoking the right one does not need guesswork. */
29
+ name: string;
30
+ /** sha256 of the token, base64url. The token itself was never written down. */
31
+ hash: string;
32
+ created: string;
33
+ lastUsed?: string;
34
+ revoked?: string;
35
+ }
36
+
37
+ let held: Token[] | undefined;
38
+
39
+ function read(): Token[] {
40
+ if (held) return held;
41
+ try {
42
+ held = JSON.parse(readFileSync(FILE, "utf8")) as Token[];
43
+ } catch (error) {
44
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
45
+ held = [];
46
+ }
47
+ return held;
48
+ }
49
+
50
+ function write(tokens: Token[]): void {
51
+ mkdirSync(STATE, { recursive: true });
52
+ writeFileSync(FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
53
+ chmodSync(FILE, 0o600);
54
+ held = tokens;
55
+ }
56
+
57
+ /** Every token, revoked ones included, newest first. Never the secrets: there are none to give. */
58
+ export function tokens(): Token[] {
59
+ return [...read()].sort((a, b) => b.created.localeCompare(a.created));
60
+ }
61
+
62
+ /**
63
+ * A new token. The returned `secret` is the only time it exists in one piece,
64
+ * so whatever asked for it has to hand it over now or make another.
65
+ */
66
+ export function makeToken(name: string): { secret: string; token: Token } {
67
+ const called = name.trim();
68
+ if (!called) throw new Error("Give the token a name, so you know what you are revoking later.");
69
+ const secret = PREFIX + crypto.randomBytes(BYTES).toString("base64url");
70
+ const token: Token = {
71
+ id: crypto.randomBytes(8).toString("hex"),
72
+ name: called,
73
+ hash: digest(secret),
74
+ created: new Date().toISOString(),
75
+ };
76
+ write([...read(), token]);
77
+ return { secret, token };
78
+ }
79
+
80
+ /**
81
+ * Stops a token working, now. The record stays so the list can show what was
82
+ * revoked and when: a token that vanishes leaves nobody able to answer "was
83
+ * that one ever real".
84
+ */
85
+ export function revokeToken(id: string): Token {
86
+ const all = read();
87
+ const found = all.find((one) => one.id === id);
88
+ if (!found) throw new Error("No token with that id.");
89
+ if (!found.revoked) {
90
+ found.revoked = new Date().toISOString();
91
+ write(all);
92
+ }
93
+ return found;
94
+ }
95
+
96
+ /**
97
+ * The token this value is, or null. Comparing the hashes rather than the
98
+ * values means a wrong guess never gets to see how much of it was right.
99
+ */
100
+ export function checkToken(value: string): Token | null {
101
+ if (!value.startsWith(PREFIX)) return null;
102
+ const want = digest(value);
103
+ const found = read().find((one) => !one.revoked && same(one.hash, want));
104
+ if (found) touch(found);
105
+ return found ?? null;
106
+ }
107
+
108
+ /**
109
+ * When it was last used, to the minute. To the minute because every call would
110
+ * otherwise rewrite this file, and the answer nobody needs is the second.
111
+ */
112
+ function touch(token: Token): void {
113
+ const now = new Date().toISOString();
114
+ if (token.lastUsed && now.slice(0, 16) === token.lastUsed.slice(0, 16)) return;
115
+ token.lastUsed = now;
116
+ write(read());
117
+ }
118
+
119
+ function digest(value: string): string {
120
+ return crypto.createHash("sha256").update(value).digest("base64url");
121
+ }
122
+
123
+ function same(a: string, b: string): boolean {
124
+ const one = Buffer.from(a), two = Buffer.from(b);
125
+ if (one.length !== two.length) return false;
126
+ return crypto.timingSafeEqual(one, two);
127
+ }
128
+
129
+ /** Forgets what was read, so a test can write the file and be believed. */
130
+ export function forgetTokens(): void {
131
+ held = undefined;
132
+ }
package/server.ts ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ // The server.
3
+ //
4
+ // It loads every agent, starts the clock, opens one port, and watches the tree
5
+ // so an edit is live without a restart. It names no agent: chloe.config.ts
6
+ // lists them, so adding one is adding it to that list.
7
+ //
8
+ // If this process is not running, nothing fires.
9
+ import { existsSync, readdirSync, watch, type FSWatcher } from "node:fs";
10
+
11
+ import { ROOT } from "#chloe/core/paths.ts";
12
+ import { closeCutOff, trim } from "#chloe/core/db.ts";
13
+ import { loadAll, type Agent, type Running } from "#chloe/load/load.ts";
14
+ import { via } from "#chloe/model/model.ts";
15
+ import { HOST, PORT, serve } from "#chloe/serve/http.ts";
16
+ import { startClock } from "#chloe/core/clock.ts";
17
+
18
+ // Credentials a channel reads from the environment can be kept in .env beside
19
+ // the repo, which is not in source control.
20
+ if (existsSync(`${ROOT}/.env`)) process.loadEnvFile(`${ROOT}/.env`);
21
+
22
+ let agents: Map<string, Agent> = await loadAll();
23
+ trim();
24
+ const cutOff = closeCutOff();
25
+ if (cutOff) console.log(`closed ${cutOff} run${cutOff === 1 ? "" : "s"} the last stop cut off`);
26
+
27
+ const clock = startClock(() => agents);
28
+
29
+ // Every way in that is not the API: the channels each agent names. A channel
30
+ // keeps running across a reload unless a file in that agent's channels/
31
+ // folder changed, because restarting one drops whatever it was halfway
32
+ // through reading.
33
+ const running = new Map<string, Running>();
34
+
35
+ function startChannels(changed: Set<string> = new Set()): void {
36
+ const wanted = new Set<string>();
37
+ for (const agent of agents.values()) {
38
+ for (const one of agent.channels) {
39
+ const key = `${agent.name}/${one.name}`;
40
+ wanted.add(key);
41
+ if (running.has(key) && !changed.has(agent.name)) continue;
42
+ running.get(key)?.stop();
43
+ running.set(key, one.start(() => agents.get(agent.name)));
44
+ }
45
+ }
46
+ for (const [key, one] of running) {
47
+ if (!wanted.has(key)) {
48
+ one.stop();
49
+ running.delete(key);
50
+ }
51
+ }
52
+ }
53
+ startChannels();
54
+
55
+ serve({
56
+ host: HOST,
57
+ port: PORT,
58
+ agents: () => agents,
59
+ clock,
60
+ channels: () => [...running.values()].flatMap((one) => one.routes ?? []),
61
+ });
62
+
63
+ console.log(`agents: ${[...agents.keys()].join(", ")} on http://${HOST}:${PORT}`);
64
+ console.log(`models: ${via() === "claude" ? "the claude cli, on a subscription" : "the gateway, on a key"}`);
65
+ for (const agent of agents.values()) {
66
+ for (const job of agent.jobs) {
67
+ console.log(
68
+ ` ${agent.name}/${job.id}: ${job.cron ? `${job.cron} ${job.timezone}` : "when started"}` +
69
+ (job.model ? ` on ${job.model}` : ""),
70
+ );
71
+ }
72
+ }
73
+
74
+ let pending: NodeJS.Timeout | undefined;
75
+ const changedChannels = new Set<string>();
76
+
77
+ function changed(path: string): void {
78
+ for (const agent of agents.values()) {
79
+ if (path.startsWith(`${agent.folder}/channels/`)) changedChannels.add(agent.name);
80
+ }
81
+ clearTimeout(pending);
82
+ pending = setTimeout(reload, 500);
83
+ }
84
+
85
+ // One at a time. Two at once could finish in the wrong order, and the older
86
+ // read of the files would be the one that stuck.
87
+ let reloading: Promise<void> | undefined;
88
+ let again = false;
89
+
90
+ async function reload(): Promise<void> {
91
+ if (reloading) {
92
+ again = true;
93
+ return;
94
+ }
95
+ reloading = (async () => {
96
+ do {
97
+ again = false;
98
+ try {
99
+ agents = await loadAll();
100
+ watchFolders();
101
+ startChannels(changedChannels);
102
+ changedChannels.clear();
103
+ console.log(`reloaded: ${[...agents.keys()].join(", ")}`);
104
+ } catch (error) {
105
+ console.error(
106
+ "reload failed, keeping the agents that were already loaded:",
107
+ error instanceof Error ? error.message : error,
108
+ );
109
+ }
110
+ } while (again);
111
+ })();
112
+ await reloading;
113
+ reloading = undefined;
114
+ }
115
+
116
+ /**
117
+ * Reload when chloe.config.ts or anything in an agent's folder changes.
118
+ *
119
+ * Every folder is watched on its own, not recursively. Node's recursive watch
120
+ * on Linux keeps a watch per file, and a file replaced rather than edited in
121
+ * place (as git and many editors save) is never heard from again. A folder's
122
+ * own watch sees every change inside it, however it was written.
123
+ *
124
+ * Debounced, because an editor saving one file fires several events, and a
125
+ * reload halfway through someone writing a file would load a broken one. A
126
+ * reload that throws keeps the agents that were already working, so a typo in
127
+ * one agent does not take the others down.
128
+ */
129
+ const watching = new Map<string, FSWatcher>();
130
+ const SKIP = new Set(["node_modules", ".git", "__pycache__"]);
131
+
132
+ function foldersIn(folder: string): string[] {
133
+ const found = [folder];
134
+ for (const entry of readdirSync(folder, { withFileTypes: true })) {
135
+ if (entry.isDirectory() && !SKIP.has(entry.name)) found.push(...foldersIn(`${folder}/${entry.name}`));
136
+ }
137
+ return found;
138
+ }
139
+
140
+ function watchFolders(): void {
141
+ const wanted = new Set([ROOT, ...[...agents.values()].flatMap((one) => foldersIn(one.folder))]);
142
+ for (const [folder, watcher] of watching) {
143
+ if (!wanted.has(folder)) {
144
+ watcher.close();
145
+ watching.delete(folder);
146
+ }
147
+ }
148
+ for (const folder of wanted) {
149
+ if (watching.has(folder)) continue;
150
+ const top = folder === ROOT;
151
+ const watcher = watch(folder, (_event, file) => {
152
+ if (file && (!top || file === "chloe.config.ts")) changed(`${folder}/${file}`);
153
+ });
154
+ // A folder that is deleted ends its watch with an error, which would otherwise stop the service.
155
+ watcher.on("error", () => {
156
+ watcher.close();
157
+ watching.delete(folder);
158
+ changed(folder);
159
+ });
160
+ watching.set(folder, watcher);
161
+ }
162
+ }
163
+ watchFolders();
164
+
165
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
166
+ process.on(signal, () => {
167
+ clock.stop();
168
+ process.exit(0);
169
+ });
170
+ }
package/timer/cron.ts ADDED
@@ -0,0 +1,92 @@
1
+ // Five fields, each a `*`, a number, a list, a range or a step like `*/15`.
2
+ // Sunday is 0. Day of month and day of week are both matched, unlike cron's
3
+ // rule that naming both means either: no job here names both.
4
+
5
+ /** A cron line as the five sets of numbers it means. */
6
+ export interface Cron {
7
+ minute: number[];
8
+ hour: number[];
9
+ dayOfMonth: number[];
10
+ month: number[];
11
+ dayOfWeek: number[];
12
+ }
13
+
14
+ const RANGES: [keyof Cron, number, number][] = [
15
+ ["minute", 0, 59],
16
+ ["hour", 0, 23],
17
+ ["dayOfMonth", 1, 31],
18
+ ["month", 1, 12],
19
+ ["dayOfWeek", 0, 6],
20
+ ];
21
+
22
+ /** A cron line as numbers, or a throw saying what it could not read. */
23
+ export function parse(line: string): Cron {
24
+ const fields = line.trim().split(/\s+/);
25
+ if (fields.length !== 5) {
26
+ throw new Error(`A cron line needs five fields, got ${fields.length}: ${JSON.stringify(line)}`);
27
+ }
28
+ const cron = {} as Cron;
29
+ RANGES.forEach(([name, low, high], i) => {
30
+ cron[name] = field(fields[i], low, high, name);
31
+ });
32
+ return cron;
33
+ }
34
+
35
+ function field(text: string, low: number, high: number, name: string): number[] {
36
+ const values = new Set<number>();
37
+ for (const part of text.split(",")) {
38
+ const [spec, stepText] = part.split("/");
39
+ const step = stepText ? Number(stepText) : 1;
40
+ if (!Number.isInteger(step) || step < 1) throw new Error(`Bad step in ${name}: ${part}`);
41
+
42
+ let from = low;
43
+ let to = high;
44
+ if (spec !== "*") {
45
+ const bounds = spec.split("-").map(Number);
46
+ if (bounds.some((n) => !Number.isInteger(n))) throw new Error(`Bad ${name}: ${part}`);
47
+ from = bounds[0];
48
+ to = bounds.length > 1 ? bounds[1] : stepText ? high : bounds[0];
49
+ }
50
+ if (from < low || to > high || from > to) throw new Error(`${name} out of range (${low}-${high}): ${part}`);
51
+ for (let n = from; n <= to; n += step) values.add(n);
52
+ }
53
+ return [...values].sort((a, b) => a - b);
54
+ }
55
+
56
+ /** Whether a cron line is due at that moment, in that timezone. */
57
+ export function due(cron: Cron, at: Date, timezone = "UTC"): boolean {
58
+ const { minute, hour, dayOfMonth, month, dayOfWeek } = inZone(at, timezone);
59
+ return (
60
+ cron.minute.includes(minute) &&
61
+ cron.hour.includes(hour) &&
62
+ cron.dayOfMonth.includes(dayOfMonth) &&
63
+ cron.month.includes(month) &&
64
+ cron.dayOfWeek.includes(dayOfWeek)
65
+ );
66
+ }
67
+
68
+ // Intl is the only thing here that knows when New York changed its clocks. An
69
+ // offset worked out by hand is wrong twice a year.
70
+ function inZone(at: Date, timezone: string) {
71
+ const parts = new Intl.DateTimeFormat("en-GB", {
72
+ timeZone: timezone,
73
+ hour12: false,
74
+ year: "numeric",
75
+ month: "2-digit",
76
+ day: "2-digit",
77
+ hour: "2-digit",
78
+ minute: "2-digit",
79
+ weekday: "short",
80
+ }).formatToParts(at);
81
+
82
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
83
+ const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
84
+ return {
85
+ minute: Number(get("minute")),
86
+ // Some locales write midnight as 24.
87
+ hour: Number(get("hour")) % 24,
88
+ dayOfMonth: Number(get("day")),
89
+ month: Number(get("month")),
90
+ dayOfWeek: days.indexOf(get("weekday")),
91
+ };
92
+ }
package/timer/every.ts ADDED
@@ -0,0 +1,153 @@
1
+ // When a job runs, written the way it is said, and turned into a cron line.
2
+ //
3
+ // every(15).minutes "*/15 * * * *"
4
+ // every(4).hours "0 */4 * * *"
5
+ // every.hour.at(30) "30 * * * *" (at(0) is on the hour)
6
+ // every.day.at("07:00") "0 7 * * *"
7
+ // every.day.at("10:45", "22:45") "45 10,22 * * *"
8
+ // every.weekday.at("9:30") "30 9 * * 1-5"
9
+ // every.monday.at("9:00") "0 9 * * 1"
10
+ // every.month.on(1).at("09:00") "0 9 1 * *"
11
+ //
12
+ // What comes out is an ordinary cron line, so the loader, the clock and the
13
+ // page read it like any other. Anything a cron line cannot say, or would say
14
+ // differently from how it reads here, is refused with the reason rather than
15
+ // rounded: every(7).minutes would run at :56 and again at :00, so it is not
16
+ // "every 7 minutes", and it is not written.
17
+ //
18
+ // describe() is the other direction, for the page: a line this file could have
19
+ // written comes back as words, and anything else is left as the cron line.
20
+
21
+ const DAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] as const;
22
+
23
+ /** Times of day, "HH:MM" on a 24 hour clock. Several must share the minute, because one cron line has only one. */
24
+ interface OnDays {
25
+ at(...times: string[]): string;
26
+ }
27
+
28
+ /**
29
+ * `every(15).minutes` and `every(4).hours`, as a cron line. A count that does
30
+ * not divide the hour or the day evenly is refused with the reason.
31
+ */
32
+ export function every(count: number): { readonly minutes: string; readonly hours: string } {
33
+ return {
34
+ get minutes() {
35
+ evenly(count, 60, "minutes");
36
+ return `*/${count} * * * *`;
37
+ },
38
+ get hours() {
39
+ evenly(count, 24, "hours");
40
+ return `0 */${count} * * *`;
41
+ },
42
+ };
43
+ }
44
+
45
+ every.minute = "* * * * *";
46
+ every.hour = {
47
+ /** Minutes past the hour: every.hour.at(0) is on the hour. */
48
+ at(minute: number): string {
49
+ if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
50
+ throw new Error(`every.hour.at(${minute}): the minute past the hour is a whole number from 0 to 59.`);
51
+ }
52
+ return `${minute} * * * *`;
53
+ },
54
+ };
55
+ every.day = onDays("*", "every.day");
56
+ every.weekday = onDays("1-5", "every.weekday");
57
+ every.weekend = onDays("0,6", "every.weekend");
58
+ every.sunday = onDays("0", "every.sunday");
59
+ every.monday = onDays("1", "every.monday");
60
+ every.tuesday = onDays("2", "every.tuesday");
61
+ every.wednesday = onDays("3", "every.wednesday");
62
+ every.thursday = onDays("4", "every.thursday");
63
+ every.friday = onDays("5", "every.friday");
64
+ every.saturday = onDays("6", "every.saturday");
65
+ every.month = {
66
+ on(day: number): OnDays {
67
+ // 29 and later are left out on purpose: a job on the 31st would skip
68
+ // every short month without saying so.
69
+ if (!Number.isInteger(day) || day < 1 || day > 28) {
70
+ throw new Error(`every.month.on(${day}): the day is 1 to 28, so it happens in every month.`);
71
+ }
72
+ return onDays("*", "every.month", String(day));
73
+ },
74
+ };
75
+
76
+ function onDays(days: string, where: string, dayOfMonth = "*"): OnDays {
77
+ return {
78
+ at(...list) {
79
+ const { minute, hours } = times(list, where);
80
+ return `${minute} ${hours.join(",")} ${dayOfMonth} * ${days}`;
81
+ },
82
+ };
83
+ }
84
+
85
+ function times(list: string[], where: string): { minute: number; hours: number[] } {
86
+ if (list.length === 0) throw new Error(`${where}.at() needs a time, like "07:00".`);
87
+ const read = list.map((time) => {
88
+ const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
89
+ const hour = Number(match?.[1]);
90
+ const minute = Number(match?.[2]);
91
+ if (!match || hour > 23 || minute > 59) {
92
+ throw new Error(`${JSON.stringify(time)} is not a time. Write it on a 24 hour clock, like "07:00" or "22:45".`);
93
+ }
94
+ return { hour, minute };
95
+ });
96
+ if (new Set(read.map((one) => one.minute)).size > 1) {
97
+ throw new Error(`${list.join(", ")} do not share a minute, and one cron line has only one. Make them two jobs.`);
98
+ }
99
+ return { minute: read[0].minute, hours: [...new Set(read.map((one) => one.hour))].sort((a, b) => a - b) };
100
+ }
101
+
102
+ /** Only a count that divides the hour or the day, so the gap is the same every time. */
103
+ function evenly(count: number, of: number, unit: string): void {
104
+ if (Number.isInteger(count) && count > 1 && count < of && of % count === 0) return;
105
+ const fits = Array.from({ length: of - 2 }, (_, i) => i + 2).filter((n) => of % n === 0);
106
+ const one = unit === "minutes" ? "every.minute" : "every.hour.at(0)";
107
+ throw new Error(
108
+ count === 1
109
+ ? `every(1).${unit} is ${one}.`
110
+ : `every(${count}).${unit} does not divide ${unit === "minutes" ? "an hour" : "a day"} evenly, ` +
111
+ `so the gaps would not all be the same. It can be ${fits.join(", ")}.`,
112
+ );
113
+ }
114
+
115
+ /** A cron line in words, when it is one this file could have written. */
116
+ export function describe(cron: string, timezone = "UTC"): string | undefined {
117
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = cron.trim().split(/\s+/);
118
+ if (month !== "*") return undefined;
119
+ const zone = timezone === "UTC" ? "UTC" : timezone.split("/").pop()!.replace(/_/g, " ");
120
+
121
+ if (dayOfMonth === "*" && dayOfWeek === "*") {
122
+ if (cron.trim() === "* * * * *") return "every minute";
123
+ const minutes = /^\*\/(\d+)$/.exec(minute);
124
+ if (minutes && hour === "*" && minutes[1] !== "1") return `every ${minutes[1]} minutes`;
125
+ const hours = /^\*\/(\d+)$/.exec(hour);
126
+ if (hours && minute === "0" && hours[1] !== "1") return `every ${hours[1]} hours`;
127
+ if (/^\d+$/.test(minute) && hour === "*") return minute === "0" ? "every hour" : `every hour at :${pad(minute)}`;
128
+ }
129
+
130
+ if (!/^\d+$/.test(minute) || !/^\d+(,\d+)*$/.test(hour)) return undefined;
131
+ const at = hour
132
+ .split(",")
133
+ .map((h) => `${pad(h)}:${pad(minute)}`)
134
+ .join(" and ");
135
+
136
+ const days =
137
+ dayOfWeek === "*" ? "every day" :
138
+ dayOfWeek === "1-5" ? "weekdays" :
139
+ dayOfWeek === "0,6" ? "weekends" :
140
+ /^[0-6]$/.test(dayOfWeek) ? `${DAYS[Number(dayOfWeek)]}s` :
141
+ undefined;
142
+ if (!days) return undefined;
143
+ if (dayOfMonth === "*") return `${days} at ${at} ${zone}`;
144
+ if (dayOfWeek === "*" && /^\d+$/.test(dayOfMonth)) return `on the ${nth(Number(dayOfMonth))} of every month at ${at} ${zone}`;
145
+ return undefined;
146
+ }
147
+
148
+ const pad = (n: string) => n.padStart(2, "0");
149
+
150
+ function nth(n: number): string {
151
+ const end = n % 10 === 1 && n !== 11 ? "st" : n % 10 === 2 && n !== 12 ? "nd" : n % 10 === 3 && n !== 13 ? "rd" : "th";
152
+ return `${n}${end}`;
153
+ }
package/timer/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // Cron lines, on their own. This folder imports nothing else in chloe, so it
2
+ // can be used without the rest: `import { every, due, parse } from "@chloejs/core/timer"`.
3
+ export { due, parse, type Cron } from "./cron.ts";
4
+ export { describe, every } from "./every.ts";