@getfamiliar/shared 0.1.2

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 (83) hide show
  1. package/build/AgentRun.d.ts +187 -0
  2. package/build/AgentRun.js +1 -0
  3. package/build/AgentRunBus.d.ts +147 -0
  4. package/build/AgentRunBus.js +362 -0
  5. package/build/Calendar.d.ts +450 -0
  6. package/build/Calendar.js +37 -0
  7. package/build/ChatMessage.d.ts +53 -0
  8. package/build/ChatMessage.js +11 -0
  9. package/build/ChatMessageBus.d.ts +110 -0
  10. package/build/ChatMessageBus.js +257 -0
  11. package/build/Config.d.ts +83 -0
  12. package/build/Config.js +1 -0
  13. package/build/ContainerTool.d.ts +34 -0
  14. package/build/ContainerTool.js +1 -0
  15. package/build/CronExpression.d.ts +53 -0
  16. package/build/CronExpression.js +77 -0
  17. package/build/Event.d.ts +280 -0
  18. package/build/Event.js +14 -0
  19. package/build/EventBus.d.ts +198 -0
  20. package/build/EventBus.js +482 -0
  21. package/build/HandlerCatalog.d.ts +75 -0
  22. package/build/HandlerCatalog.js +165 -0
  23. package/build/InferenceEvent.d.ts +30 -0
  24. package/build/InferenceEvent.js +1 -0
  25. package/build/InferenceEventBus.d.ts +39 -0
  26. package/build/InferenceEventBus.js +80 -0
  27. package/build/Mail.d.ts +250 -0
  28. package/build/Mail.js +73 -0
  29. package/build/MailStyleTemplate.d.ts +71 -0
  30. package/build/MailStyleTemplate.js +47 -0
  31. package/build/ModelMetaData.d.ts +63 -0
  32. package/build/ModelMetaData.js +12 -0
  33. package/build/PathGlob.d.ts +20 -0
  34. package/build/PathGlob.js +33 -0
  35. package/build/Plugin.d.ts +709 -0
  36. package/build/Plugin.js +29 -0
  37. package/build/PostgresConnection.d.ts +75 -0
  38. package/build/PostgresConnection.js +162 -0
  39. package/build/ScheduledSubagent.d.ts +49 -0
  40. package/build/ScheduledSubagent.js +1 -0
  41. package/build/ScheduledSubagentBus.d.ts +94 -0
  42. package/build/ScheduledSubagentBus.js +187 -0
  43. package/build/Schema.d.ts +84 -0
  44. package/build/Schema.js +570 -0
  45. package/build/StepResult.d.ts +103 -0
  46. package/build/StepResult.js +1 -0
  47. package/build/StepResultBus.d.ts +64 -0
  48. package/build/StepResultBus.js +169 -0
  49. package/build/Timezone.d.ts +58 -0
  50. package/build/Timezone.js +84 -0
  51. package/build/ToolCall.d.ts +25 -0
  52. package/build/ToolCall.js +1 -0
  53. package/build/ToolCallBus.d.ts +65 -0
  54. package/build/ToolCallBus.js +110 -0
  55. package/build/ToolLevel.d.ts +20 -0
  56. package/build/ToolLevel.js +2 -0
  57. package/build/ToolRunner.d.ts +118 -0
  58. package/build/ToolRunner.js +192 -0
  59. package/build/ToolsExpressionUtils.d.ts +80 -0
  60. package/build/ToolsExpressionUtils.js +102 -0
  61. package/build/WorkspaceFile.d.ts +104 -0
  62. package/build/WorkspaceFile.js +7 -0
  63. package/build/estimateTokens.d.ts +16 -0
  64. package/build/estimateTokens.js +18 -0
  65. package/build/index.d.ts +37 -0
  66. package/build/index.js +26 -0
  67. package/build/logging/Logger.d.ts +66 -0
  68. package/build/logging/Logger.js +90 -0
  69. package/build/markdownHtml.d.ts +14 -0
  70. package/build/markdownHtml.js +21 -0
  71. package/build/markdownTerminal.d.ts +14 -0
  72. package/build/markdownTerminal.js +376 -0
  73. package/build/testing/MockAgentRunBus.d.ts +52 -0
  74. package/build/testing/MockAgentRunBus.js +175 -0
  75. package/build/testing/MockBusStore.d.ts +22 -0
  76. package/build/testing/MockBusStore.js +22 -0
  77. package/build/testing/MockEventBus.d.ts +20 -0
  78. package/build/testing/MockEventBus.js +57 -0
  79. package/build/testing/MockLogger.d.ts +52 -0
  80. package/build/testing/MockLogger.js +65 -0
  81. package/build/testing/index.d.ts +4 -0
  82. package/build/testing/index.js +4 -0
  83. package/package.json +63 -0
@@ -0,0 +1,257 @@
1
+ import { CHATMESSAGES_NEW_CHANNEL } from "./Schema.js";
2
+ /**
3
+ * Domain client for the `chatmessages` table.
4
+ *
5
+ * Three responsibilities:
6
+ *
7
+ * 1. Persistence of assistant replies (`insertAssistantMessage`). User
8
+ * messages are persisted by `EventBus.add` directly when the event
9
+ * has `isChat=true`, so they don't have a public method here.
10
+ * 2. History retrieval for the agent (`fetchHistoryForEvent`). Returns
11
+ * every message on the same channel as the given event, oldest
12
+ * first. The container's `ChatManager` wraps this for the LLM.
13
+ * 3. At-least-once channel delivery (`subscribe`). New subscribers
14
+ * replay all undelivered matching rows on registration; live rows
15
+ * arrive via the `chatmessages_new` NOTIFY channel. A handler
16
+ * returning `true` marks the row delivered; multiple matching
17
+ * listeners is fine — the UPDATE is idempotent.
18
+ *
19
+ * The bus has no notion of channel-binding: callers pass `eventId` or
20
+ * the full filter on each call. Channel routing happens via JOIN to
21
+ * `events.preferred_chat_channel_id`, NOT a denormalized column on
22
+ * `chatmessages`.
23
+ */
24
+ export class ChatMessageBus {
25
+ connection;
26
+ log;
27
+ constructor(connection, log) {
28
+ this.connection = connection;
29
+ this.log = log;
30
+ }
31
+ /**
32
+ * Insert one assistant reply. Returns the persisted row. Used by
33
+ * the container's `send_chat` tool via `ChatManager`.
34
+ */
35
+ async insertAssistantMessage(eventId, textContent) {
36
+ return this.insert({ eventId, role: "assistant", textContent });
37
+ }
38
+ /**
39
+ * Insert a row of any role. The atomic-with-event INSERT path used
40
+ * by `EventBus.add` for user messages bypasses this method (it
41
+ * issues the INSERT inside its own transaction); this is provided
42
+ * for tests and for callers that already hold their own
43
+ * transaction.
44
+ */
45
+ async insert(input) {
46
+ const result = await this.connection.getPool().query(`INSERT INTO chatmessages (event_id, role, text_content)
47
+ VALUES ($1, $2, $3)
48
+ RETURNING *`, [input.eventId, input.role, input.textContent]);
49
+ return mapRow(result.rows[0]);
50
+ }
51
+ /** Fetch one row by id. Returns `undefined` if not found. */
52
+ async getById(id) {
53
+ const result = await this.connection
54
+ .getPool()
55
+ .query(`SELECT * FROM chatmessages WHERE id = $1`, [id]);
56
+ return result.rows.length > 0 ? mapRow(result.rows[0]) : undefined;
57
+ }
58
+ /**
59
+ * Return every chat message on the same channel as the given event,
60
+ * oldest first. The channel is resolved via the join
61
+ * `chatmessages.event_id → events.preferred_chat_channel_id`,
62
+ * which lets the container request "the conversation this agentrun
63
+ * is part of" without ever naming a channel itself.
64
+ *
65
+ * If the parent event has no `preferred_chat_channel_id`, only
66
+ * messages tied to that exact event are returned (no cross-event
67
+ * thread to attach to).
68
+ */
69
+ async fetchHistoryForEvent(eventId) {
70
+ const result = await this.connection.getPool().query(`WITH anchor AS (
71
+ SELECT preferred_chat_channel_id AS channel
72
+ FROM events WHERE id = $1
73
+ )
74
+ SELECT cm.*
75
+ FROM chatmessages cm
76
+ JOIN events e ON e.id = cm.event_id
77
+ CROSS JOIN anchor
78
+ WHERE (
79
+ anchor.channel IS NOT NULL
80
+ AND e.preferred_chat_channel_id = anchor.channel
81
+ ) OR (
82
+ anchor.channel IS NULL
83
+ AND cm.event_id = $1
84
+ )
85
+ ORDER BY cm.created_at ASC, cm.id ASC`, [eventId]);
86
+ return result.rows.map(mapRow);
87
+ }
88
+ /**
89
+ * Subscribe to chat messages matching `filter`. Returns an
90
+ * unsubscribe function.
91
+ *
92
+ * Delivery semantics:
93
+ *
94
+ * - LISTEN is attached BEFORE the replay SELECT, so any row inserted
95
+ * between SELECT and attach won't be missed. NOTIFY-driven
96
+ * delivery is still gated by `delivered_at IS NULL`, so the same
97
+ * row is never delivered twice via this subscription.
98
+ * - The replay SELECT returns every undelivered matching row in
99
+ * `created_at` order; each is awaited through the handler.
100
+ * - When the handler returns `true`, the bus runs
101
+ * `UPDATE chatmessages SET delivered_at = now() WHERE id = $1
102
+ * AND delivered_at IS NULL` — a no-op if a concurrent subscriber
103
+ * has already marked it.
104
+ * - Handler errors are caught and logged so one bad subscriber
105
+ * doesn't break others.
106
+ *
107
+ * Multiple matching subscribers will each see the row; the first
108
+ * to return `true` "wins" the delivery write. This is idempotent
109
+ * and matches the at-least-once contract: handlers must tolerate
110
+ * being called for an already-displayed message.
111
+ */
112
+ async subscribe(filter, handler) {
113
+ const seenLive = new Set();
114
+ const dispatch = async (message) => {
115
+ if (!matchesFilter(message, filter)) {
116
+ return;
117
+ }
118
+ if (message.deliveredAt !== null) {
119
+ return;
120
+ }
121
+ try {
122
+ const ok = await handler(message);
123
+ if (ok) {
124
+ await this.markDelivered(message.id);
125
+ }
126
+ }
127
+ catch (err) {
128
+ console.error(`ChatMessageBus subscribe handler error for id=${message.id}: ${err instanceof Error ? err.message : String(err)}`);
129
+ }
130
+ };
131
+ const listenHandler = (payload) => {
132
+ this.log?.debug({ channel: CHATMESSAGES_NEW_CHANNEL, payload }, "NOTIFY chatmessages_new");
133
+ void this.dispatchNotification(payload, filter, seenLive, dispatch);
134
+ };
135
+ await this.connection.listen(CHATMESSAGES_NEW_CHANNEL, listenHandler);
136
+ try {
137
+ const undelivered = await this.fetchUndelivered(filter);
138
+ for (const row of undelivered) {
139
+ seenLive.add(row.id);
140
+ await dispatch(row);
141
+ }
142
+ }
143
+ catch (err) {
144
+ await this.connection.unlisten(CHATMESSAGES_NEW_CHANNEL, listenHandler);
145
+ throw err;
146
+ }
147
+ let disposed = false;
148
+ return async () => {
149
+ if (disposed) {
150
+ return;
151
+ }
152
+ disposed = true;
153
+ await this.connection.unlisten(CHATMESSAGES_NEW_CHANNEL, listenHandler);
154
+ };
155
+ }
156
+ /**
157
+ * Parse a NOTIFY payload, prefilter on the prefix, fetch the row,
158
+ * and hand it to the dispatcher. The `seenLive` set deduplicates
159
+ * against the replay SELECT so a row picked up by both paths is
160
+ * delivered exactly once per subscription.
161
+ */
162
+ async dispatchNotification(payload, filter, seenLive, dispatch) {
163
+ const parsed = parseNotifyPayload(payload);
164
+ if (!parsed) {
165
+ return;
166
+ }
167
+ if (filter.channelId !== undefined && parsed.channelId !== filter.channelId) {
168
+ return;
169
+ }
170
+ if (filter.role !== undefined && parsed.role !== filter.role) {
171
+ return;
172
+ }
173
+ if (seenLive.has(parsed.id)) {
174
+ return;
175
+ }
176
+ seenLive.add(parsed.id);
177
+ try {
178
+ const row = await this.getById(parsed.id);
179
+ if (!row) {
180
+ return;
181
+ }
182
+ await dispatch(row);
183
+ }
184
+ catch (err) {
185
+ console.error(`ChatMessageBus notification dispatch error for id=${parsed.id}: ${err instanceof Error ? err.message : String(err)}`);
186
+ }
187
+ }
188
+ /**
189
+ * SELECT all undelivered rows that match `filter`, oldest first.
190
+ * Channel filter joins to `events.preferred_chat_channel_id`.
191
+ */
192
+ async fetchUndelivered(filter) {
193
+ const conditions = ["cm.delivered_at IS NULL"];
194
+ const values = [];
195
+ let n = 1;
196
+ if (filter.channelId !== undefined) {
197
+ conditions.push(`e.preferred_chat_channel_id = $${n++}`);
198
+ values.push(filter.channelId);
199
+ }
200
+ if (filter.role !== undefined) {
201
+ conditions.push(`cm.role = $${n++}`);
202
+ values.push(filter.role);
203
+ }
204
+ const sql = `SELECT cm.*
205
+ FROM chatmessages cm
206
+ JOIN events e ON e.id = cm.event_id
207
+ WHERE ${conditions.join(" AND ")}
208
+ ORDER BY cm.created_at ASC, cm.id ASC`;
209
+ const result = await this.connection.getPool().query(sql, values);
210
+ return result.rows.map(mapRow);
211
+ }
212
+ /** Conditional UPDATE to set delivered_at; no-op if already set. */
213
+ async markDelivered(id) {
214
+ await this.connection.getPool().query(`UPDATE chatmessages SET delivered_at = now()
215
+ WHERE id = $1 AND delivered_at IS NULL`, [id]);
216
+ }
217
+ }
218
+ /** Convert a snake_case raw row into the camelCase {@link ChatMessage}. */
219
+ function mapRow(raw) {
220
+ return {
221
+ id: raw.id,
222
+ eventId: raw.event_id,
223
+ role: raw.role,
224
+ textContent: raw.text_content,
225
+ createdAt: raw.created_at,
226
+ deliveredAt: raw.delivered_at,
227
+ };
228
+ }
229
+ /**
230
+ * Parse the `<channel_id>:<role>:<id>` NOTIFY payload. Returns
231
+ * `undefined` if the shape is unexpected. The channel id may be empty
232
+ * (when the parent event had no `preferred_chat_channel_id`); we keep
233
+ * it as the empty string in that case.
234
+ */
235
+ function parseNotifyPayload(payload) {
236
+ const firstColon = payload.indexOf(":");
237
+ if (firstColon < 0) {
238
+ return undefined;
239
+ }
240
+ const lastColon = payload.lastIndexOf(":");
241
+ if (lastColon === firstColon) {
242
+ return undefined;
243
+ }
244
+ const channelId = payload.slice(0, firstColon);
245
+ const role = payload.slice(firstColon + 1, lastColon);
246
+ const id = payload.slice(lastColon + 1);
247
+ if (role !== "user" && role !== "assistant") {
248
+ return undefined;
249
+ }
250
+ return { channelId, role, id };
251
+ }
252
+ function matchesFilter(message, filter) {
253
+ if (filter.role !== undefined && message.role !== filter.role) {
254
+ return false;
255
+ }
256
+ return true;
257
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Plugin-agnostic configuration surface exposed by the host.
3
+ *
4
+ * Plugins reach configuration through `HostContext.config` rather than
5
+ * touching `process.env` or reading the YAML file themselves. The
6
+ * service intentionally has no plugin-specific types; everything is
7
+ * keyed by dotted path strings (e.g. `"core.postgresPassword"`,
8
+ * `"telegram.botToken"`) and validated to a primitive shape at the
9
+ * call site.
10
+ *
11
+ * Required-vs-optional semantics live in the call signature, not in
12
+ * the schema: omitting `defaultValue` makes the read throw on missing
13
+ * or wrong-shaped data, while supplying one widens the return type to
14
+ * include the default (so plugins can pass `null` for fields they
15
+ * tolerate being absent).
16
+ *
17
+ * The platform-level minimum (the keys without which the daemon can't
18
+ * start) is enforced by a separate `ConfigLinter` on the host side at
19
+ * boot. Plugin-side keys are not policed by the platform — plugins own
20
+ * the validation of their own subtrees.
21
+ */
22
+ export interface ConfigService {
23
+ /**
24
+ * Read a string under a dotted path. Throws when the key is
25
+ * missing, the resolved value is not a string, or the string is
26
+ * empty.
27
+ */
28
+ getString(key: string): string;
29
+ /**
30
+ * String read with an opt-out default. The return type widens to
31
+ * `string | T` so callers can pass `null` for optional fields and
32
+ * branch on absence without losing type safety.
33
+ */
34
+ getString<T>(key: string, defaultValue: T): string | T;
35
+ /** Number read; same throw-on-missing semantics as {@link getString}. */
36
+ getNumber(key: string): number;
37
+ /** Number read with default; same widening semantics as {@link getString}. */
38
+ getNumber<T>(key: string, defaultValue: T): number | T;
39
+ /**
40
+ * Boolean read. Throws when the key is missing or the resolved
41
+ * value is not a boolean. YAML's native `true` / `false` parse to
42
+ * booleans, so a stanza like `inference.captureModelHttpRequestBodies: true` reads
43
+ * back as `true` here.
44
+ */
45
+ getBool(key: string): boolean;
46
+ /** Boolean read with default; widening as above. */
47
+ getBool<T>(key: string, defaultValue: T): boolean | T;
48
+ /**
49
+ * Array read. Element types are not validated — the caller checks
50
+ * shape if it cares (e.g. `.filter((x): x is string => typeof x === "string")`).
51
+ * Throws when the key is missing or the resolved value is not an
52
+ * array.
53
+ */
54
+ getArray(key: string): readonly unknown[];
55
+ /** Array read with default; widening as above. */
56
+ getArray<T>(key: string, defaultValue: T): readonly unknown[] | T;
57
+ /**
58
+ * Read a "string or list of strings" config value as a normalized
59
+ * `string[]`. A bare string becomes a one-element list; an array is
60
+ * filtered down to its non-empty string members; anything else (or
61
+ * a missing key) falls back to `defaultValue`. Lets the operator
62
+ * write either `key: foo/**` or `key: ["foo/**", "bar/*"]` and have
63
+ * both read back the same way.
64
+ *
65
+ * Distinct from {@link getArray}, which returns the default (losing
66
+ * the value) when the config holds a bare string rather than an
67
+ * array.
68
+ */
69
+ getStringList(key: string, defaultValue: readonly string[]): readonly string[];
70
+ /**
71
+ * Set a single dotted-path key and persist atomically (write
72
+ * temp + rename). Creates intermediate maps as needed.
73
+ *
74
+ * `value` may be any YAML-serializable primitive, array, or plain
75
+ * object. The implementation re-reads from disk before patching
76
+ * so concurrent writes from another process don't get clobbered
77
+ * silently.
78
+ *
79
+ * @throws If the value cannot be serialized as YAML, or if the
80
+ * atomic write fails.
81
+ */
82
+ set(key: string, value: unknown): Promise<void>;
83
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import type { ToolLevel } from "./ToolLevel.js";
2
+ /**
3
+ * Metadata for one container-side built-in tool (`send_chat`, `fs_*`,
4
+ * `bash`, …), as the agent container reports it to the host on startup.
5
+ *
6
+ * Container built-ins are constructed per-agentrun *inside* the
7
+ * container and never pass through any host-side registry, so the host
8
+ * cannot read them directly. Instead the container enumerates them once
9
+ * at startup (`ToolsFactory.catalog()`) and POSTs this projection to the
10
+ * bastion's `/container-tools/` endpoint; the host keeps the latest
11
+ * report in an in-memory registry that the `tools list` CLI reads. This
12
+ * is the single source of truth — there is no hand-maintained host-side
13
+ * copy to drift out of sync.
14
+ */
15
+ export interface ContainerToolInfo {
16
+ /** Agent-facing tool name / registration key (e.g. `fs_read`). */
17
+ readonly name: string;
18
+ /** Human-readable description, verbatim from the tool's builder. */
19
+ readonly description: string;
20
+ /** Raw JSON Schema for the tool's arguments. */
21
+ readonly inputSchema: object;
22
+ /**
23
+ * Curated groups this built-in joins (mirrors
24
+ * `CONTAINER_TOOL_GROUPS` in the container), e.g. `["core", "fs"]`
25
+ * for `fs_read` or `["bash"]` for `bash`.
26
+ */
27
+ readonly groups: readonly string[];
28
+ /**
29
+ * Security classification (mirrors `CONTAINER_TOOL_LEVELS` in the
30
+ * container). See {@link ToolLevel}; `default` unless the built-in
31
+ * is listed there.
32
+ */
33
+ readonly level: ToolLevel;
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Result of {@link parseCron} when the input was accepted by either
3
+ * the friendly-node-cron grammar or by Croner directly.
4
+ */
5
+ export interface ParsedCron {
6
+ /** Verbatim string from the handler's frontmatter. */
7
+ readonly verbatim: string;
8
+ /** Cron expression Croner runs (friendly output, or the verbatim). */
9
+ readonly expression: string;
10
+ /** Which path accepted the input. */
11
+ readonly source: "friendly" | "raw";
12
+ }
13
+ /**
14
+ * Parse a cron expression. friendly-node-cron is tried first because
15
+ * its grammar (`every monday at 8 am`) is a strict superset of cron in
16
+ * the directions we care about: it never silently mis-parses a cron
17
+ * expression. When friendly returns `null` (no human-pattern match) we
18
+ * validate the verbatim against Croner.
19
+ *
20
+ * The verbatim string is first run through {@link normalizeFriendly}
21
+ * to insert an implicit "at" between a day token (`every day`, `every
22
+ * monday`, `every weekday`, …) and a leading time digit. Without that
23
+ * rewrite, friendly-node-cron either returns null for inputs like
24
+ * `every day 4:00am` *or* — worse — silently mis-parses `every monday
25
+ * 4:00am` to midnight (`0 0 0 * * 1`) because it accepts the day token
26
+ * but doesn't see the hour. Idempotent: applying the rewrite to an
27
+ * already-well-formed expression is a no-op.
28
+ *
29
+ * Lives in `shared/` so both the host's `CronjobScheduler` (handler-
30
+ * frontmatter cron) and plugin pollers (e.g. ms365's calendar refresh
31
+ * cron) reach the same parsing contract.
32
+ *
33
+ * @returns `null` when neither path produces a runnable schedule.
34
+ */
35
+ export declare function parseCron(verbatim: string): ParsedCron | null;
36
+ /**
37
+ * Insert a missing `at` between a day token and a leading digit so
38
+ * friendly-node-cron sees a shape it understands.
39
+ *
40
+ * The rewrite triggers on:
41
+ *
42
+ * - `everyday <digit>` → `everyday at <digit>`
43
+ * - `every <word> <digit>` → `every <word> at <digit>` (catches
44
+ * `every day 4:00am`, `every monday 4:00am`, `every weekday 4:00am`,
45
+ * `every weekend 4:00am`, etc.)
46
+ *
47
+ * `every 5 minutes` and `every 2 hours` are unaffected because the
48
+ * token after `every` consumes the digit so the trailing `\d` check
49
+ * doesn't match.
50
+ *
51
+ * Exported for unit tests; not in the public index.
52
+ */
53
+ export declare function normalizeFriendly(input: string): string;
@@ -0,0 +1,77 @@
1
+ import { Cron } from "croner";
2
+ import friendly from "friendly-node-cron";
3
+ /**
4
+ * Parse a cron expression. friendly-node-cron is tried first because
5
+ * its grammar (`every monday at 8 am`) is a strict superset of cron in
6
+ * the directions we care about: it never silently mis-parses a cron
7
+ * expression. When friendly returns `null` (no human-pattern match) we
8
+ * validate the verbatim against Croner.
9
+ *
10
+ * The verbatim string is first run through {@link normalizeFriendly}
11
+ * to insert an implicit "at" between a day token (`every day`, `every
12
+ * monday`, `every weekday`, …) and a leading time digit. Without that
13
+ * rewrite, friendly-node-cron either returns null for inputs like
14
+ * `every day 4:00am` *or* — worse — silently mis-parses `every monday
15
+ * 4:00am` to midnight (`0 0 0 * * 1`) because it accepts the day token
16
+ * but doesn't see the hour. Idempotent: applying the rewrite to an
17
+ * already-well-formed expression is a no-op.
18
+ *
19
+ * Lives in `shared/` so both the host's `CronjobScheduler` (handler-
20
+ * frontmatter cron) and plugin pollers (e.g. ms365's calendar refresh
21
+ * cron) reach the same parsing contract.
22
+ *
23
+ * @returns `null` when neither path produces a runnable schedule.
24
+ */
25
+ export function parseCron(verbatim) {
26
+ const friendlyResult = tryFriendly(verbatim);
27
+ if (friendlyResult !== null) {
28
+ if (isValidExpression(friendlyResult)) {
29
+ return { verbatim, expression: friendlyResult, source: "friendly" };
30
+ }
31
+ }
32
+ if (isValidExpression(verbatim)) {
33
+ return { verbatim, expression: verbatim, source: "raw" };
34
+ }
35
+ return null;
36
+ }
37
+ function tryFriendly(verbatim) {
38
+ const normalized = normalizeFriendly(verbatim);
39
+ try {
40
+ const out = friendly(normalized);
41
+ return typeof out === "string" && out.length > 0 ? out : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /**
48
+ * Insert a missing `at` between a day token and a leading digit so
49
+ * friendly-node-cron sees a shape it understands.
50
+ *
51
+ * The rewrite triggers on:
52
+ *
53
+ * - `everyday <digit>` → `everyday at <digit>`
54
+ * - `every <word> <digit>` → `every <word> at <digit>` (catches
55
+ * `every day 4:00am`, `every monday 4:00am`, `every weekday 4:00am`,
56
+ * `every weekend 4:00am`, etc.)
57
+ *
58
+ * `every 5 minutes` and `every 2 hours` are unaffected because the
59
+ * token after `every` consumes the digit so the trailing `\d` check
60
+ * doesn't match.
61
+ *
62
+ * Exported for unit tests; not in the public index.
63
+ */
64
+ export function normalizeFriendly(input) {
65
+ return input.replace(/^(\s*(?:everyday|every\s+\w+))\s+(\d)/i, "$1 at $2");
66
+ }
67
+ /** True iff Croner can construct a (paused) schedule from `expression`. */
68
+ function isValidExpression(expression) {
69
+ try {
70
+ const job = new Cron(expression, { paused: true }, () => { });
71
+ job.stop();
72
+ return true;
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ }