@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.
- package/build/AgentRun.d.ts +187 -0
- package/build/AgentRun.js +1 -0
- package/build/AgentRunBus.d.ts +147 -0
- package/build/AgentRunBus.js +362 -0
- package/build/Calendar.d.ts +450 -0
- package/build/Calendar.js +37 -0
- package/build/ChatMessage.d.ts +53 -0
- package/build/ChatMessage.js +11 -0
- package/build/ChatMessageBus.d.ts +110 -0
- package/build/ChatMessageBus.js +257 -0
- package/build/Config.d.ts +83 -0
- package/build/Config.js +1 -0
- package/build/ContainerTool.d.ts +34 -0
- package/build/ContainerTool.js +1 -0
- package/build/CronExpression.d.ts +53 -0
- package/build/CronExpression.js +77 -0
- package/build/Event.d.ts +280 -0
- package/build/Event.js +14 -0
- package/build/EventBus.d.ts +198 -0
- package/build/EventBus.js +482 -0
- package/build/HandlerCatalog.d.ts +75 -0
- package/build/HandlerCatalog.js +165 -0
- package/build/InferenceEvent.d.ts +30 -0
- package/build/InferenceEvent.js +1 -0
- package/build/InferenceEventBus.d.ts +39 -0
- package/build/InferenceEventBus.js +80 -0
- package/build/Mail.d.ts +250 -0
- package/build/Mail.js +73 -0
- package/build/MailStyleTemplate.d.ts +71 -0
- package/build/MailStyleTemplate.js +47 -0
- package/build/ModelMetaData.d.ts +63 -0
- package/build/ModelMetaData.js +12 -0
- package/build/PathGlob.d.ts +20 -0
- package/build/PathGlob.js +33 -0
- package/build/Plugin.d.ts +709 -0
- package/build/Plugin.js +29 -0
- package/build/PostgresConnection.d.ts +75 -0
- package/build/PostgresConnection.js +162 -0
- package/build/ScheduledSubagent.d.ts +49 -0
- package/build/ScheduledSubagent.js +1 -0
- package/build/ScheduledSubagentBus.d.ts +94 -0
- package/build/ScheduledSubagentBus.js +187 -0
- package/build/Schema.d.ts +84 -0
- package/build/Schema.js +570 -0
- package/build/StepResult.d.ts +103 -0
- package/build/StepResult.js +1 -0
- package/build/StepResultBus.d.ts +64 -0
- package/build/StepResultBus.js +169 -0
- package/build/Timezone.d.ts +58 -0
- package/build/Timezone.js +84 -0
- package/build/ToolCall.d.ts +25 -0
- package/build/ToolCall.js +1 -0
- package/build/ToolCallBus.d.ts +65 -0
- package/build/ToolCallBus.js +110 -0
- package/build/ToolLevel.d.ts +20 -0
- package/build/ToolLevel.js +2 -0
- package/build/ToolRunner.d.ts +118 -0
- package/build/ToolRunner.js +192 -0
- package/build/ToolsExpressionUtils.d.ts +80 -0
- package/build/ToolsExpressionUtils.js +102 -0
- package/build/WorkspaceFile.d.ts +104 -0
- package/build/WorkspaceFile.js +7 -0
- package/build/estimateTokens.d.ts +16 -0
- package/build/estimateTokens.js +18 -0
- package/build/index.d.ts +37 -0
- package/build/index.js +26 -0
- package/build/logging/Logger.d.ts +66 -0
- package/build/logging/Logger.js +90 -0
- package/build/markdownHtml.d.ts +14 -0
- package/build/markdownHtml.js +21 -0
- package/build/markdownTerminal.d.ts +14 -0
- package/build/markdownTerminal.js +376 -0
- package/build/testing/MockAgentRunBus.d.ts +52 -0
- package/build/testing/MockAgentRunBus.js +175 -0
- package/build/testing/MockBusStore.d.ts +22 -0
- package/build/testing/MockBusStore.js +22 -0
- package/build/testing/MockEventBus.d.ts +20 -0
- package/build/testing/MockEventBus.js +57 -0
- package/build/testing/MockLogger.d.ts +52 -0
- package/build/testing/MockLogger.js +65 -0
- package/build/testing/index.d.ts +4 -0
- package/build/testing/index.js +4 -0
- 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
|
+
}
|
package/build/Config.js
ADDED
|
@@ -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
|
+
}
|