@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,482 @@
1
+ import { EVENTS_NEW_CHANNEL, SCHEMA_SQL } from "./Schema.js";
2
+ /**
3
+ * Thrown by {@link EventBus.add} when an event's `idempotencyKey`
4
+ * collides with one already in the `events` table (Postgres unique
5
+ * violation `23505` on `events_idempotency_key_key`).
6
+ *
7
+ * The error is deliberately typed rather than swallowed: an emitter
8
+ * that re-derives the same key on a re-walk (the ms365 mail delta poll,
9
+ * the calendar poll, chat compaction, …) is expected to `catch` this
10
+ * and treat the collision as a no-op — the event already exists. Other
11
+ * emitters that should never collide let it propagate as a real fault.
12
+ */
13
+ export class DuplicateIdempotencyKeyError extends Error {
14
+ /** The colliding idempotency key, for logging by the catching emitter. */
15
+ idempotencyKey;
16
+ /**
17
+ * @param idempotencyKey - The key that already existed in `events`.
18
+ */
19
+ constructor(idempotencyKey) {
20
+ super(`event with idempotency key "${idempotencyKey}" already exists`);
21
+ this.name = "DuplicateIdempotencyKeyError";
22
+ this.idempotencyKey = idempotencyKey;
23
+ }
24
+ }
25
+ /**
26
+ * True when `err` is the Postgres unique-constraint violation raised by
27
+ * inserting a duplicate `events.idempotency_key`. Mirrors the detection
28
+ * idiom previously hand-rolled in `ChatCompactor`/`WhatsAppDaemon`:
29
+ * pg error code `23505`, narrowed to the idempotency-key constraint so
30
+ * an unrelated unique violation isn't misclassified.
31
+ *
32
+ * @param err - The error thrown by the INSERT.
33
+ * @returns Whether it is the idempotency-key duplicate violation.
34
+ */
35
+ function isIdempotencyKeyViolation(err) {
36
+ if (!(err instanceof Error)) {
37
+ return false;
38
+ }
39
+ const code = err.code;
40
+ if (code !== "23505") {
41
+ return false;
42
+ }
43
+ const constraint = err.constraint;
44
+ if (constraint) {
45
+ return constraint === "events_idempotency_key_key";
46
+ }
47
+ return err.message.includes("idempotency_key");
48
+ }
49
+ /**
50
+ * Domain client for the `events` table that backs the host ↔ container bus.
51
+ *
52
+ * Uses an injected {@link PostgresConnection} for pool + LISTEN access;
53
+ * does not own connection lifecycle. Several `EventBus` instances can
54
+ * share a connection, and the connection can be reused by other domain
55
+ * clients (e.g. {@link AgentRunBus}) without opening more sockets.
56
+ *
57
+ * Listens on `events_new` for wake-ups — the channel that fires on
58
+ * INSERT into `events`. State updates (which fire on `events_state`)
59
+ * are not consumed here; host plugins that need to wait for an event to
60
+ * settle should subscribe to that channel directly via
61
+ * {@link PostgresConnection.listen}.
62
+ *
63
+ * `waitForNext()` does an initial query before sleeping on the listen
64
+ * channel, so events that already existed when the call started are
65
+ * picked up immediately.
66
+ *
67
+ * The bus is *not* a multi-consumer queue: two waiters may observe the
68
+ * same event. Caller is responsible for transitioning state via
69
+ * {@link update} after handling.
70
+ */
71
+ export class EventBus {
72
+ connection;
73
+ log;
74
+ notifyWaiters = [];
75
+ listenInstalled = false;
76
+ listenHandler = (payload) => {
77
+ this.log?.debug({ channel: EVENTS_NEW_CHANNEL, payload }, "NOTIFY events_new");
78
+ const waiters = this.notifyWaiters.splice(0);
79
+ for (const wake of waiters) {
80
+ wake();
81
+ }
82
+ };
83
+ /**
84
+ * @param connection - Shared pg pool / LISTEN handle.
85
+ * @param log - Optional logger; when set, NOTIFY arrivals are
86
+ * debug-logged. Omitted on the host-side bus that runs inside
87
+ * one-shot CLI commands so they stay quiet by default.
88
+ */
89
+ constructor(connection, log) {
90
+ this.connection = connection;
91
+ this.log = log;
92
+ }
93
+ /**
94
+ * Apply the events + agentruns schema (idempotent). Safe to call on
95
+ * every daemon start. The same SQL bundle is consumed by
96
+ * {@link AgentRunBus.installSchema}, so calling either is sufficient.
97
+ */
98
+ async installSchema() {
99
+ await this.connection.getPool().query(SCHEMA_SQL);
100
+ }
101
+ /**
102
+ * Insert a new event and return the persisted row.
103
+ *
104
+ * Transactional: when `event.isChat === true`, a `chatmessages`
105
+ * row (role `'user'`) carrying the same `prompt` text is inserted
106
+ * in the same transaction. Both NOTIFY triggers fire post-COMMIT,
107
+ * so listeners always observe consistent state.
108
+ *
109
+ * Validation: `prompt` must be a non-empty string after trimming —
110
+ * for any event, chat or not. The single check exists at this
111
+ * boundary so plugins that bypass the type system (untyped JS,
112
+ * unsafe casts) still surface the bug at emit time instead of
113
+ * leaving the AgentRunner with an empty messages array.
114
+ *
115
+ * `beforeCommit` runs **inside the transaction** after the event
116
+ * row (and any chat mirror) is inserted but before COMMIT. Use it
117
+ * for filesystem side-effects that must be atomic with the INSERT
118
+ * from the watcher's perspective — most importantly, staging
119
+ * `event.files` under `<scratchDir>/<row.id>/` so the container
120
+ * never observes an event without its scratch files in place.
121
+ * Throwing from `beforeCommit` rolls the INSERT back; cleaning up
122
+ * any partial filesystem state is the callback's responsibility.
123
+ *
124
+ * @throws {DuplicateIdempotencyKeyError} If `idempotencyKey` collides
125
+ * with an existing event. Emitters that re-derive stable keys on a
126
+ * re-walk are expected to catch this and treat it as a no-op.
127
+ * @throws If `topic` does not match `\w+(:\w+)*`, if `prompt` is
128
+ * missing or whitespace-only, or if `beforeCommit` throws.
129
+ */
130
+ async add(event, beforeCommit) {
131
+ if (typeof event.prompt !== "string" || event.prompt.trim().length === 0) {
132
+ throw new Error("EventBus.add: every event requires a non-empty `prompt` (the agent-visible text)");
133
+ }
134
+ const isChat = event.isChat === true;
135
+ const prompt = event.prompt;
136
+ const client = await this.connection.getPool().connect();
137
+ try {
138
+ await client.query("BEGIN");
139
+ const result = await client.query(`INSERT INTO events
140
+ (topic, payload, priority, state, idempotency_key,
141
+ is_chat, preferred_chat_channel_id, prompt, privileged,
142
+ start_handler, output_chat_on_failure)
143
+ VALUES ($1, $2::jsonb, $3, $4, $5, $6, $7, $8, $9, $10, $11)
144
+ RETURNING *`, [
145
+ event.topic,
146
+ JSON.stringify(event.payload ?? {}),
147
+ event.priority ?? 50,
148
+ event.state ?? "pending",
149
+ event.idempotencyKey ?? null,
150
+ isChat,
151
+ event.preferredChatChannelId ?? null,
152
+ prompt,
153
+ event.privileged ?? false,
154
+ event.startHandler ?? null,
155
+ event.outputChatOnFailure ?? false,
156
+ ]);
157
+ const row = mapRow(result.rows[0]);
158
+ // Atomic user-chatmessage insert. Two modes:
159
+ // - `userChatMessage` set: insert that text verbatim,
160
+ // irrespective of isChat. Used by plugins (cli-chat
161
+ // direct calls) that want a chatmessage text different
162
+ // from `prompt`.
163
+ // - else `isChat: true`: insert `prompt` (back-compat
164
+ // with chat-event emitters that don't know about the
165
+ // new field).
166
+ //
167
+ // Doing the INSERT here, inside the same transaction as the
168
+ // event INSERT, ensures the FK row-lock is released before
169
+ // NOTIFY events_new fires post-commit — otherwise the
170
+ // input-event watcher's `FOR UPDATE SKIP LOCKED` claim can
171
+ // race with the chatmessages lock and leave the event
172
+ // stuck `pending` forever (the NOTIFY waiter is consumed
173
+ // even though `claim()` returned zero rows).
174
+ const chatMessageText = event.userChatMessage ?? (isChat ? prompt : undefined);
175
+ if (chatMessageText !== undefined) {
176
+ await client.query(`INSERT INTO chatmessages (event_id, role, text_content)
177
+ VALUES ($1, 'user', $2)`, [row.id, chatMessageText]);
178
+ }
179
+ if (beforeCommit) {
180
+ await beforeCommit(row);
181
+ }
182
+ await client.query("COMMIT");
183
+ return row;
184
+ }
185
+ catch (err) {
186
+ try {
187
+ await client.query("ROLLBACK");
188
+ }
189
+ catch {
190
+ // best-effort rollback; preserve original error
191
+ }
192
+ // Translate the raw pg unique violation into a typed error so
193
+ // emitters that legitimately re-derive the same idempotency
194
+ // key (delta-poll re-walks, chat compaction races) can catch
195
+ // it by type instead of string-matching the pg message.
196
+ if (event.idempotencyKey && isIdempotencyKeyViolation(err)) {
197
+ throw new DuplicateIdempotencyKeyError(event.idempotencyKey);
198
+ }
199
+ throw err;
200
+ }
201
+ finally {
202
+ client.release();
203
+ }
204
+ }
205
+ /**
206
+ * Fetch one event by id. Returns `undefined` if not found. Mirrors
207
+ * {@link import("./StepResultBus").StepResultBus.getById} so
208
+ * notification consumers can promote a payload-only id into a full
209
+ * typed row without a hand-written query.
210
+ */
211
+ async getById(id) {
212
+ const result = await this.connection
213
+ .getPool()
214
+ .query(`SELECT * FROM events WHERE id = $1`, [id]);
215
+ return result.rows.length > 0 ? mapRow(result.rows[0]) : undefined;
216
+ }
217
+ /**
218
+ * Fetch all event rows whose `updated_at` is `>= since`, ordered by
219
+ * `(updated_at, id)`. Used by the polling-based report layer to
220
+ * pick up new inserts and state transitions without subscribing to
221
+ * NOTIFY. Returns an empty array when nothing changed since the
222
+ * anchor.
223
+ */
224
+ async listSince(since) {
225
+ const result = await this.connection.getPool().query(`SELECT * FROM events
226
+ WHERE updated_at >= $1
227
+ ORDER BY updated_at, id`, [since]);
228
+ return result.rows.map(mapRow);
229
+ }
230
+ /**
231
+ * Fetch the most recently created N event rows, newest first. Used by
232
+ * `./cli.sh events list` to surface recent bus activity for operators
233
+ * looking for an event id to inspect with `events report <id>`.
234
+ *
235
+ * @param limit Maximum number of rows to return. Must be a positive integer.
236
+ * @returns Up to `limit` event rows ordered by descending `(created_at, id)`.
237
+ */
238
+ async listLatest(limit) {
239
+ const result = await this.connection.getPool().query(`SELECT * FROM events
240
+ ORDER BY created_at DESC, id DESC
241
+ LIMIT $1`, [limit]);
242
+ return result.rows.map(mapRow);
243
+ }
244
+ /**
245
+ * Fetch the most recently created N event rows whose topic, start_handler,
246
+ * prompt, or payload (cast to text) case-insensitively contains `needle`.
247
+ * Used by `./cli.sh events list <search>` so the operator can grep the
248
+ * recent bus without piping through `grep`.
249
+ *
250
+ * Substring match via `ILIKE '%needle%'`; the needle is escaped for the
251
+ * SQL `LIKE` metacharacters `%`, `_`, and `\`. No index is added — at
252
+ * interactive scale a sequential scan is fine.
253
+ *
254
+ * @param limit Maximum number of rows to return. Must be a positive integer.
255
+ * @param needle Case-insensitive substring to search for.
256
+ * @returns Up to `limit` matching event rows ordered by descending `(created_at, id)`.
257
+ */
258
+ async searchLatest(limit, needle) {
259
+ const pattern = `%${needle.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
260
+ const result = await this.connection.getPool().query(`SELECT * FROM events
261
+ WHERE topic ILIKE $2
262
+ OR coalesce(start_handler, '') ILIKE $2
263
+ OR coalesce(prompt, '') ILIKE $2
264
+ OR payload::text ILIKE $2
265
+ ORDER BY created_at DESC, id DESC
266
+ LIMIT $1`, [limit, pattern]);
267
+ return result.rows.map(mapRow);
268
+ }
269
+ /**
270
+ * Latest N event rows matching every supplied filter. Drives the
271
+ * agent-facing `event_list` reflection tool, which lets the agent
272
+ * narrow by state, date window, and free-text needle simultaneously
273
+ * — combinations the older {@link listLatest} / {@link searchLatest}
274
+ * pair can't express. Both of those stay as the CLI's call sites.
275
+ *
276
+ * Substring match (when `search` is set) is the same ILIKE-against-
277
+ * `(topic, start_handler, prompt, payload::text)` predicate
278
+ * {@link searchLatest} uses. `dateFrom` is inclusive, `dateTo` is
279
+ * exclusive (`created_at >= $from AND created_at < $to`), so the
280
+ * agent can pass two midnights to get a single local day without
281
+ * worrying about the boundary instant.
282
+ */
283
+ async listFiltered(filter) {
284
+ const conditions = [];
285
+ const values = [];
286
+ let n = 1;
287
+ if (filter.search !== undefined && filter.search.length > 0) {
288
+ const pattern = `%${filter.search.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
289
+ conditions.push(`(topic ILIKE $${n} OR coalesce(start_handler, '') ILIKE $${n} ` +
290
+ `OR coalesce(prompt, '') ILIKE $${n} OR payload::text ILIKE $${n})`);
291
+ values.push(pattern);
292
+ n++;
293
+ }
294
+ if (filter.state !== undefined) {
295
+ conditions.push(`state = $${n++}`);
296
+ values.push(filter.state);
297
+ }
298
+ if (filter.dateFrom !== undefined) {
299
+ conditions.push(`created_at >= $${n++}`);
300
+ values.push(filter.dateFrom);
301
+ }
302
+ if (filter.dateTo !== undefined) {
303
+ conditions.push(`created_at < $${n++}`);
304
+ values.push(filter.dateTo);
305
+ }
306
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
307
+ values.push(filter.limit);
308
+ const limitParam = `$${n}`;
309
+ const result = await this.connection.getPool().query(`SELECT * FROM events
310
+ ${where}
311
+ ORDER BY created_at DESC, id DESC
312
+ LIMIT ${limitParam}`, values);
313
+ return result.rows.map(mapRow);
314
+ }
315
+ /**
316
+ * Patch one or more fields on an existing event by id. Always bumps
317
+ * `updated_at`. No-op if `patch` contains no recognized fields.
318
+ */
319
+ async update(id, patch) {
320
+ const sets = ["updated_at = now()"];
321
+ const values = [];
322
+ let n = 1;
323
+ if (patch.state !== undefined) {
324
+ sets.push(`state = $${n++}`);
325
+ values.push(patch.state);
326
+ }
327
+ if (patch.payload !== undefined) {
328
+ sets.push(`payload = $${n++}::jsonb`);
329
+ values.push(JSON.stringify(patch.payload));
330
+ }
331
+ if (patch.priority !== undefined) {
332
+ sets.push(`priority = $${n++}`);
333
+ values.push(patch.priority);
334
+ }
335
+ if (patch.preferredChatChannelId !== undefined) {
336
+ sets.push(`preferred_chat_channel_id = $${n++}`);
337
+ values.push(patch.preferredChatChannelId);
338
+ }
339
+ values.push(id);
340
+ const idParam = `$${n}`;
341
+ await this.connection
342
+ .getPool()
343
+ .query(`UPDATE events SET ${sets.join(", ")} WHERE id = ${idParam}`, values);
344
+ }
345
+ /**
346
+ * Block until an event matching `filter` is in the table. Returns
347
+ * the highest-priority such event (FIFO within priority). Subscribes
348
+ * to `LISTEN events_new` on first call so subsequent waits sleep
349
+ * until a NOTIFY arrives instead of polling.
350
+ *
351
+ * **Read-only**: does not mutate the row. For multi-consumer queues,
352
+ * use {@link waitAndClaim} instead, which atomically transitions the
353
+ * row's state on read.
354
+ *
355
+ * @param filter - Topic / state filter; default `{ states: ["pending"] }`.
356
+ * @param signal - Optional AbortSignal to cancel the wait.
357
+ * @throws If aborted via `signal`.
358
+ */
359
+ async waitForNext(filter = {}, signal) {
360
+ await this.ensureListening();
361
+ for (;;) {
362
+ if (signal?.aborted) {
363
+ throw new Error("waitForNext aborted");
364
+ }
365
+ const row = await this.queryOne(filter);
366
+ if (row) {
367
+ return row;
368
+ }
369
+ await this.waitForNotification(signal);
370
+ }
371
+ }
372
+ /**
373
+ * Atomically transition the highest-priority event in `fromState` to
374
+ * `toState` and return the updated row. Race-safe across multiple
375
+ * concurrent workers via `FOR UPDATE SKIP LOCKED`.
376
+ *
377
+ * Returns `undefined` if no row is in `fromState`. Non-blocking — for
378
+ * blocking semantics, use {@link waitAndClaim}.
379
+ */
380
+ async claim(fromState, toState) {
381
+ const result = await this.connection.getPool().query(`UPDATE events
382
+ SET state = $1, updated_at = now()
383
+ WHERE id = (
384
+ SELECT id FROM events
385
+ WHERE state = $2
386
+ ORDER BY priority DESC, id ASC
387
+ FOR UPDATE SKIP LOCKED
388
+ LIMIT 1
389
+ )
390
+ RETURNING *`, [toState, fromState]);
391
+ return result.rows.length > 0 ? mapRow(result.rows[0]) : undefined;
392
+ }
393
+ /**
394
+ * Block until an event in `fromState` is available, then atomically
395
+ * claim it (transition to `toState`) and return it. Combines
396
+ * {@link claim} with the LISTEN/NOTIFY wait loop used by
397
+ * {@link waitForNext}.
398
+ *
399
+ * @throws If aborted via `signal`.
400
+ */
401
+ async waitAndClaim(fromState, toState, signal) {
402
+ await this.ensureListening();
403
+ for (;;) {
404
+ if (signal?.aborted) {
405
+ throw new Error("waitAndClaim aborted");
406
+ }
407
+ const row = await this.claim(fromState, toState);
408
+ if (row) {
409
+ return row;
410
+ }
411
+ await this.waitForNotification(signal);
412
+ }
413
+ }
414
+ /** Subscribe this bus's wake-handler to {@link EVENTS_NEW_CHANNEL}. */
415
+ async ensureListening() {
416
+ if (this.listenInstalled) {
417
+ return;
418
+ }
419
+ this.listenInstalled = true;
420
+ await this.connection.listen(EVENTS_NEW_CHANNEL, this.listenHandler);
421
+ }
422
+ /** Resolve as soon as the next NOTIFY arrives or `signal` aborts. */
423
+ waitForNotification(signal) {
424
+ return new Promise((resolve, reject) => {
425
+ const wake = () => {
426
+ if (signal) {
427
+ signal.removeEventListener("abort", onAbort);
428
+ }
429
+ resolve();
430
+ };
431
+ const onAbort = () => {
432
+ this.notifyWaiters = this.notifyWaiters.filter((w) => w !== wake);
433
+ reject(new Error("waitForNext aborted"));
434
+ };
435
+ this.notifyWaiters.push(wake);
436
+ if (signal) {
437
+ signal.addEventListener("abort", onAbort, { once: true });
438
+ }
439
+ });
440
+ }
441
+ /** Single SELECT for the highest-priority matching pending event. */
442
+ async queryOne(filter) {
443
+ const states = filter.states ?? ["pending"];
444
+ const conditions = ["state = ANY($1)"];
445
+ const values = [states];
446
+ let n = 2;
447
+ if (filter.topics && filter.topics.length > 0) {
448
+ conditions.push(`topic = ANY($${n++})`);
449
+ values.push(filter.topics);
450
+ }
451
+ const result = await this.connection.getPool().query(`SELECT * FROM events
452
+ WHERE ${conditions.join(" AND ")}
453
+ ORDER BY priority DESC, id ASC
454
+ LIMIT 1`, values);
455
+ return result.rows.length > 0 ? mapRow(result.rows[0]) : undefined;
456
+ }
457
+ }
458
+ /**
459
+ * Convert a snake_case raw row into the camelCase {@link EventRow}.
460
+ *
461
+ * The DB column is `text` with no CHECK constraint on the value, so
462
+ * `raw.state` is trusted to be a known {@link EventState} (we control
463
+ * every writer).
464
+ */
465
+ function mapRow(raw) {
466
+ return {
467
+ id: raw.id,
468
+ topic: raw.topic,
469
+ priority: raw.priority,
470
+ state: raw.state,
471
+ payload: raw.payload,
472
+ idempotencyKey: raw.idempotency_key,
473
+ isChat: raw.is_chat,
474
+ preferredChatChannelId: raw.preferred_chat_channel_id,
475
+ prompt: raw.prompt ?? "",
476
+ startHandler: raw.start_handler,
477
+ privileged: raw.privileged,
478
+ outputChatOnFailure: raw.output_chat_on_failure,
479
+ createdAt: raw.created_at,
480
+ updatedAt: raw.updated_at,
481
+ };
482
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * One handler entry surfaced by {@link HandlerCatalog.list}.
3
+ *
4
+ * Encodes the same notion as the container's `HandlerFile`: a topic
5
+ * (one or more `:`-separated segments) plus a basename. `slashPath`
6
+ * is the user-facing form the cli-chat prompt accepts as a direct
7
+ * handler call (e.g. `/grocery/fruits/order`).
8
+ */
9
+ export interface HandlerPath {
10
+ /** Colon-separated topic, e.g. `chat:telegram`. */
11
+ readonly topic: string;
12
+ /** Handler basename without `.md`, e.g. `index`, `analyze`. */
13
+ readonly handler: string;
14
+ /** Absolute path on disk of the handler file. */
15
+ readonly absolutePath: string;
16
+ /** Workspace-relative posix path, e.g. `chat/telegram/index.md`. */
17
+ readonly relativePath: string;
18
+ /** User-facing slash form, e.g. `/chat/telegram/index`. */
19
+ readonly slashPath: string;
20
+ }
21
+ /**
22
+ * Read-only view over the `workspace/` directory's handler files.
23
+ *
24
+ * Mirrors the resolution semantics implemented by
25
+ * `container/src/HandlerFile.ts` so host-side code (CLI, cron
26
+ * scheduler introspection, tab-completion) can answer "does this
27
+ * handler exist?" and "list every handler" without depending on
28
+ * container internals or reimplementing the topic-walk inheritance.
29
+ *
30
+ * The catalog is stateless on the disk — every method re-scans —
31
+ * so live workspace edits are reflected without a daemon restart.
32
+ * At REPL frequencies this is cheap; if a hot path later needs
33
+ * caching it can wrap an instance.
34
+ */
35
+ export declare class HandlerCatalog {
36
+ private readonly workspaceDir;
37
+ private readonly writablePathGlobs;
38
+ /**
39
+ * @param workspaceDir Absolute path of the `workspace/` directory.
40
+ * @param writablePathGlobs `core.writablePaths` globs (e.g. `wiki/**`).
41
+ * Files under these are writable by non-privileged runs and are
42
+ * therefore never handlers — they are excluded from {@link list}
43
+ * and {@link resolve} so they are never proposed or resolved as a
44
+ * handler. Mirrors the container's `HandlerFile.load` guard.
45
+ */
46
+ constructor(workspaceDir: string, writablePathGlobs?: readonly string[]);
47
+ /**
48
+ * Walk `workspace/` and return one {@link HandlerPath} per
49
+ * candidate handler file. Excludes the global-context files at
50
+ * the workspace root (`SOUL.md`, `CONTEXT.md`)
51
+ * and the `skills/` subtree. Every other `.md` file under a topic
52
+ * directory is included, even if it's a knowledge file (e.g.
53
+ * `people/anna.md`) — `HandlerCatalog` does not police what is or
54
+ * isn't a "proper" handler; that's a handler-loading concern.
55
+ *
56
+ * Sorted by `slashPath` for stable display ordering in
57
+ * tab-completion. Returns an empty array when the workspace
58
+ * directory does not exist.
59
+ */
60
+ list(): Promise<readonly HandlerPath[]>;
61
+ /**
62
+ * Resolve a handler call: given a topic (e.g. `chat:telegram`)
63
+ * and a basename (e.g. `index`), return the absolute path of the
64
+ * deepest existing handler file, mirroring the container's
65
+ * inheritance walk.
66
+ *
67
+ * For topic `a:b:c` and basename `x` the resolution tries, in
68
+ * order: `a/b/c/x.md`, `a/b/x.md`, `a/x.md`. Returns the first
69
+ * existing path or `null` if none exist. The deepest existing
70
+ * file is what `HandlerFile.load` would treat as the leaf — for
71
+ * cli-chat existence checks, that's exactly the question we
72
+ * want answered.
73
+ */
74
+ resolve(topic: string, handler: string): Promise<string | null>;
75
+ }