agent-relay-server 0.4.22 → 0.4.24

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/src/db.ts CHANGED
@@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto";
3
3
  import { VERSION } from "./config.ts";
4
4
  import type {
5
5
  AgentCard,
6
+ ActivityEvent,
7
+ ActivityEventInput,
6
8
  AgentSessionGuard,
7
9
  CreatePairInput,
8
10
  HealthCheck,
@@ -21,6 +23,9 @@ import type {
21
23
  TaskSeverity,
22
24
  TaskStatus,
23
25
  IntegrationEventInput,
26
+ InboxDraft,
27
+ InboxState,
28
+ InboxThreadState,
24
29
  TaskStatusInput,
25
30
  } from "./types";
26
31
  import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS } from "./config";
@@ -144,6 +149,48 @@ export function initDb(path: string = "agent-relay.db"): Database {
144
149
  CREATE INDEX IF NOT EXISTS idx_pairs_requester ON pairs(requester_id);
145
150
  CREATE INDEX IF NOT EXISTS idx_pairs_target ON pairs(target_id);
146
151
  CREATE INDEX IF NOT EXISTS idx_pairs_status ON pairs(status);
152
+
153
+ CREATE TABLE IF NOT EXISTS inbox_thread_state (
154
+ operator_id TEXT NOT NULL,
155
+ peer_id TEXT NOT NULL,
156
+ read_cursor_message_id INTEGER,
157
+ archived_at_message_id INTEGER,
158
+ updated_at INTEGER NOT NULL,
159
+ PRIMARY KEY (operator_id, peer_id)
160
+ );
161
+ CREATE INDEX IF NOT EXISTS idx_inbox_thread_operator ON inbox_thread_state(operator_id);
162
+
163
+ CREATE TABLE IF NOT EXISTS inbox_drafts (
164
+ operator_id TEXT NOT NULL,
165
+ peer_id TEXT NOT NULL,
166
+ body TEXT NOT NULL,
167
+ subject TEXT,
168
+ channel TEXT,
169
+ updated_at INTEGER NOT NULL,
170
+ PRIMARY KEY (operator_id, peer_id)
171
+ );
172
+ CREATE INDEX IF NOT EXISTS idx_inbox_drafts_operator ON inbox_drafts(operator_id);
173
+
174
+ CREATE TABLE IF NOT EXISTS activity_events (
175
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
176
+ operator_id TEXT,
177
+ client_id TEXT UNIQUE,
178
+ kind TEXT NOT NULL,
179
+ title TEXT NOT NULL,
180
+ body TEXT,
181
+ meta_text TEXT,
182
+ icon TEXT,
183
+ view TEXT,
184
+ peer_id TEXT,
185
+ message_id INTEGER,
186
+ pair_id TEXT,
187
+ task_id INTEGER,
188
+ agent_id TEXT,
189
+ metadata TEXT NOT NULL DEFAULT '{}',
190
+ created_at INTEGER NOT NULL
191
+ );
192
+ CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
193
+ CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
147
194
  `);
148
195
 
149
196
  // Migrations
@@ -257,6 +304,44 @@ function parseStringArray(raw: string): string[] {
257
304
  return parsed.filter((value): value is string => typeof value === "string");
258
305
  }
259
306
 
307
+ function normalizeTags(tags: string[] | undefined): string[] {
308
+ return [...new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))];
309
+ }
310
+
311
+ function stringValue(value: unknown): string | undefined {
312
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
313
+ }
314
+
315
+ function inferProviderTag(input: RegisterAgentInput): "claude" | "codex" | undefined {
316
+ const meta = input.meta ?? {};
317
+ const values = [
318
+ input.id,
319
+ input.name,
320
+ input.rig,
321
+ ...(input.tags ?? []),
322
+ stringValue(meta.provider),
323
+ stringValue(meta.client),
324
+ stringValue(meta.runtime),
325
+ stringValue(meta.agentType),
326
+ ].filter((value): value is string => typeof value === "string");
327
+
328
+ if (values.some((value) => value.toLowerCase().includes("codex"))) return "codex";
329
+ if (values.some((value) => value.toLowerCase().includes("claude"))) return "claude";
330
+
331
+ // Older Claude hooks did not always send an explicit provider tag, but did
332
+ // send Claude-style approval metadata. Codex also sends approvalMode, so this
333
+ // fallback only runs after Codex signals have been ruled out.
334
+ if (Object.prototype.hasOwnProperty.call(meta, "approvalMode")) return "claude";
335
+ return undefined;
336
+ }
337
+
338
+ function tagsWithProvider(input: RegisterAgentInput): string[] {
339
+ const tags = normalizeTags(input.tags);
340
+ const provider = inferProviderTag(input);
341
+ if (!provider || tags.includes(provider)) return tags;
342
+ return [provider, ...tags];
343
+ }
344
+
260
345
  function rowToAgent(row: any): AgentCard {
261
346
  return {
262
347
  id: row.id,
@@ -355,6 +440,48 @@ function rowToPair(row: any): PairSession {
355
440
  };
356
441
  }
357
442
 
443
+ function rowToInboxThreadState(row: any): InboxThreadState {
444
+ return {
445
+ operatorId: row.operator_id,
446
+ peerId: row.peer_id,
447
+ readCursorMessageId: row.read_cursor_message_id ?? undefined,
448
+ archivedAtMessageId: row.archived_at_message_id ?? undefined,
449
+ updatedAt: row.updated_at,
450
+ };
451
+ }
452
+
453
+ function rowToInboxDraft(row: any): InboxDraft {
454
+ return {
455
+ operatorId: row.operator_id,
456
+ peerId: row.peer_id,
457
+ body: row.body,
458
+ subject: row.subject ?? undefined,
459
+ channel: row.channel ?? undefined,
460
+ updatedAt: row.updated_at,
461
+ };
462
+ }
463
+
464
+ function rowToActivityEvent(row: any): ActivityEvent {
465
+ return {
466
+ id: row.id,
467
+ operatorId: row.operator_id ?? undefined,
468
+ clientId: row.client_id ?? undefined,
469
+ kind: row.kind,
470
+ title: row.title,
471
+ body: row.body ?? undefined,
472
+ meta: row.meta_text ?? undefined,
473
+ icon: row.icon ?? undefined,
474
+ view: row.view ?? undefined,
475
+ peer: row.peer_id ?? undefined,
476
+ messageId: row.message_id ?? undefined,
477
+ pairId: row.pair_id ?? undefined,
478
+ taskId: row.task_id ?? undefined,
479
+ agentId: row.agent_id ?? undefined,
480
+ metadata: parseJson(row.metadata, {}),
481
+ createdAt: row.created_at,
482
+ };
483
+ }
484
+
358
485
  const MSG_SELECT = `SELECT m.*, (
359
486
  SELECT json_group_array(agent_id) FROM message_reads WHERE message_id = m.id
360
487
  ) AS read_by_agents FROM messages m`;
@@ -363,6 +490,7 @@ const MSG_SELECT = `SELECT m.*, (
363
490
 
364
491
  export function upsertAgent(input: RegisterAgentInput): AgentCard {
365
492
  const now = Date.now();
493
+ const tags = tagsWithProvider(input);
366
494
  // Preserve the existing label across re-registrations unless the caller
367
495
  // explicitly sends one (including null to clear).
368
496
  const labelProvided = Object.prototype.hasOwnProperty.call(input, "label");
@@ -394,7 +522,7 @@ export function upsertAgent(input: RegisterAgentInput): AgentCard {
394
522
  $name: input.name,
395
523
  $label: input.label ?? null,
396
524
  $labelProvided: labelProvided ? 1 : 0,
397
- $tags: JSON.stringify(input.tags ?? []),
525
+ $tags: JSON.stringify(tags),
398
526
  $machine: input.machine ?? null,
399
527
  $rig: input.rig ?? null,
400
528
  $capabilities: JSON.stringify(input.capabilities ?? []),
@@ -1423,6 +1551,129 @@ export function markRead(messageId: number, agentId: string): boolean {
1423
1551
  return true;
1424
1552
  }
1425
1553
 
1554
+ export function getInboxState(operatorId: string): InboxState {
1555
+ const threads = (db.prepare(
1556
+ "SELECT * FROM inbox_thread_state WHERE operator_id = ? ORDER BY updated_at DESC",
1557
+ ).all(operatorId) as any[]).map(rowToInboxThreadState);
1558
+ const drafts = (db.prepare(
1559
+ "SELECT * FROM inbox_drafts WHERE operator_id = ? ORDER BY updated_at DESC",
1560
+ ).all(operatorId) as any[]).map(rowToInboxDraft);
1561
+ return { operatorId, threads, drafts };
1562
+ }
1563
+
1564
+ export function setInboxThreadState(input: {
1565
+ operatorId: string;
1566
+ peerId: string;
1567
+ readCursorMessageId?: number | null;
1568
+ archivedAtMessageId?: number | null;
1569
+ }): InboxThreadState {
1570
+ const now = Date.now();
1571
+ const current = db.prepare(
1572
+ "SELECT * FROM inbox_thread_state WHERE operator_id = ? AND peer_id = ?",
1573
+ ).get(input.operatorId, input.peerId) as any | undefined;
1574
+
1575
+ const readCursorMessageId = Object.prototype.hasOwnProperty.call(input, "readCursorMessageId")
1576
+ ? input.readCursorMessageId ?? null
1577
+ : current?.read_cursor_message_id ?? null;
1578
+ const archivedAtMessageId = Object.prototype.hasOwnProperty.call(input, "archivedAtMessageId")
1579
+ ? input.archivedAtMessageId ?? null
1580
+ : current?.archived_at_message_id ?? null;
1581
+
1582
+ db.prepare(`
1583
+ INSERT INTO inbox_thread_state (operator_id, peer_id, read_cursor_message_id, archived_at_message_id, updated_at)
1584
+ VALUES (?, ?, ?, ?, ?)
1585
+ ON CONFLICT(operator_id, peer_id) DO UPDATE SET
1586
+ read_cursor_message_id = excluded.read_cursor_message_id,
1587
+ archived_at_message_id = excluded.archived_at_message_id,
1588
+ updated_at = excluded.updated_at
1589
+ `).run(input.operatorId, input.peerId, readCursorMessageId, archivedAtMessageId, now);
1590
+
1591
+ return rowToInboxThreadState(db.prepare(
1592
+ "SELECT * FROM inbox_thread_state WHERE operator_id = ? AND peer_id = ?",
1593
+ ).get(input.operatorId, input.peerId));
1594
+ }
1595
+
1596
+ export function setInboxDraft(input: {
1597
+ operatorId: string;
1598
+ peerId: string;
1599
+ body: string;
1600
+ subject?: string | null;
1601
+ channel?: string | null;
1602
+ }): InboxDraft {
1603
+ const now = Date.now();
1604
+ db.prepare(`
1605
+ INSERT INTO inbox_drafts (operator_id, peer_id, body, subject, channel, updated_at)
1606
+ VALUES (?, ?, ?, ?, ?, ?)
1607
+ ON CONFLICT(operator_id, peer_id) DO UPDATE SET
1608
+ body = excluded.body,
1609
+ subject = excluded.subject,
1610
+ channel = excluded.channel,
1611
+ updated_at = excluded.updated_at
1612
+ `).run(input.operatorId, input.peerId, input.body, input.subject ?? null, input.channel ?? null, now);
1613
+
1614
+ return rowToInboxDraft(db.prepare(
1615
+ "SELECT * FROM inbox_drafts WHERE operator_id = ? AND peer_id = ?",
1616
+ ).get(input.operatorId, input.peerId));
1617
+ }
1618
+
1619
+ export function deleteInboxDraft(operatorId: string, peerId: string): boolean {
1620
+ return db.prepare("DELETE FROM inbox_drafts WHERE operator_id = ? AND peer_id = ?").run(operatorId, peerId).changes > 0;
1621
+ }
1622
+
1623
+ export function listActivityEvents(input: {
1624
+ operatorId?: string;
1625
+ limit?: number;
1626
+ since?: number;
1627
+ } = {}): ActivityEvent[] {
1628
+ const conditions: string[] = [];
1629
+ const params: any[] = [];
1630
+ if (input.operatorId) {
1631
+ conditions.push("operator_id = ?");
1632
+ params.push(input.operatorId);
1633
+ }
1634
+ if (input.since !== undefined) {
1635
+ conditions.push("created_at >= ?");
1636
+ params.push(input.since);
1637
+ }
1638
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
1639
+ params.push(input.limit ?? 200);
1640
+ return (db.prepare(
1641
+ `SELECT * FROM activity_events ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
1642
+ ).all(...params) as any[]).map(rowToActivityEvent);
1643
+ }
1644
+
1645
+ export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
1646
+ if (input.clientId) {
1647
+ const existing = db.prepare("SELECT * FROM activity_events WHERE client_id = ?").get(input.clientId) as any | undefined;
1648
+ if (existing) return rowToActivityEvent(existing);
1649
+ }
1650
+ const now = Date.now();
1651
+ const result = db.prepare(`
1652
+ INSERT INTO activity_events (
1653
+ operator_id, client_id, kind, title, body, meta_text, icon, view, peer_id,
1654
+ message_id, pair_id, task_id, agent_id, metadata, created_at
1655
+ )
1656
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1657
+ `).run(
1658
+ input.operatorId ?? null,
1659
+ input.clientId ?? null,
1660
+ input.kind,
1661
+ input.title,
1662
+ input.body ?? null,
1663
+ input.meta ?? null,
1664
+ input.icon ?? null,
1665
+ input.view ?? null,
1666
+ input.peer ?? null,
1667
+ input.messageId ?? null,
1668
+ input.pairId ?? null,
1669
+ input.taskId ?? null,
1670
+ input.agentId ?? null,
1671
+ JSON.stringify(input.metadata ?? {}),
1672
+ now,
1673
+ );
1674
+ return rowToActivityEvent(db.prepare("SELECT * FROM activity_events WHERE id = ?").get(Number(result.lastInsertRowid)));
1675
+ }
1676
+
1426
1677
  export function deleteMessage(id: number): boolean {
1427
1678
  return db.transaction(() => {
1428
1679
  // Break reply_to references from children so the FK doesn't block delete.