@songsid/agend 2.1.6 → 2.1.7-beta.1

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.
@@ -1015,7 +1015,29 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1015
1015
  removeInstance(name: string, authorization: ExplicitInstanceRemoval): Promise<void>;
1016
1016
  startStatuslineWatcher(name: string): void;
1017
1017
  stopStatuslineWatcher(name: string): void;
1018
- reactMessageStatus(instanceName: string, chatId: string, messageId: string, emoji: string): void;
1018
+ /**
1019
+ * Last delivery status applied per bot+message. Unbounded by design: any cap
1020
+ * reintroduces the hole where a forgotten ❌ can never be cleared, and an
1021
+ * entry is two short strings (id pair plus one emoji) — thousands of
1022
+ * status-reacted messages cost kilobytes. A bot restart loses it, degrading
1023
+ * to a plain add (never worse than the old behaviour).
1024
+ */
1025
+ private lastStatusEmoji;
1026
+ /**
1027
+ * One in-flight status update per bot+message: updates run strictly in call
1028
+ * order, so a delayed ❌ add can never land after a newer ✅'s removal —
1029
+ * the final state is always the latest call. Entries delete themselves when
1030
+ * the chain drains, so idle messages hold no memory.
1031
+ */
1032
+ private deliveryStatusChains;
1033
+ reactMessageStatus(instanceName: string, chatId: string, messageId: string, emoji: string, threadId?: string): void;
1034
+ /**
1035
+ * Apply a terminal delivery verdict. Telegram maps ✅ to 👀 and ❌ to 👎 in
1036
+ * reactMessageStatus; Discord continues to show the verdict emoji directly.
1037
+ */
1038
+ finishDeliveryStatus(instanceName: string, chatId: string, messageId: string, emoji: string, threadId?: string): void;
1039
+ private queueDeliveryStatusReaction;
1040
+ private applyDeliveryStatusReaction;
1019
1041
  private static FAILOVER_TRIGGER_PCT;
1020
1042
  private static FAILOVER_RECOVER_PCT;
1021
1043
  checkModelFailover(name: string, fiveHourPct: number): void;
@@ -1356,13 +1378,6 @@ export declare class FleetManager implements FleetContext, LifecycleContext, Arc
1356
1378
  * Used by report_result, where the sender's self-derived name may not match
1357
1379
  * the target-address name the button was registered under. */
1358
1380
  clearCancelButtonByCorrelation(correlationId: string): void;
1359
- /**
1360
- * Reaction target chat id. Telegram reactions key on the supergroup chat_id
1361
- * (the topic thread is NOT a chat_id), so a forum-topic message must react on
1362
- * msg.chatId — reacting on threadId silently fails. Discord reactions key on
1363
- * the channel/thread id.
1364
- */
1365
- private reactTarget;
1366
1381
  /** Remember the user message just delivered, so we can react ✅ when done. */
1367
1382
  private trackInboundMsg;
1368
1383
  /** Clear the tracked last-inbound message after the agent replies. The ✅
@@ -16,6 +16,17 @@ const __filename = fileURLToPath(import.meta.url);
16
16
  const __dirname = dirname(__filename);
17
17
  /** Fallback access policy for a channel with no `access:` block — open (no gate). */
18
18
  const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
19
+ /**
20
+ * Telegram bot reactions replace a single slot per message. Keep delivery state
21
+ * presentation here so supported emoji choices can change without altering the
22
+ * status reconciliation logic.
23
+ */
24
+ const TELEGRAM_DELIVERY_STATUS_REACTIONS = new Map([
25
+ ["👀", "👀"], // queued / processing
26
+ ["⏳", "👀"],
27
+ ["✅", "👀"], // confirmed
28
+ ["❌", "👎"], // failed
29
+ ]);
19
30
  import { isProbeableRouteTarget } from "./fleet-context.js";
20
31
  import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
21
32
  import { EventLog } from "./event-log.js";
@@ -25,6 +36,7 @@ import { TmuxManager } from "./tmux-manager.js";
25
36
  import { AccessManager } from "./channel/access-manager.js";
26
37
  import { IpcClient } from "./channel/ipc-bridge.js";
27
38
  import { createAdapter } from "./channel/factory.js";
39
+ import { TelegramAdapter } from "./channel/adapters/telegram.js";
28
40
  import { createBackend } from "./backend/factory.js";
29
41
  import { isModelCompatible } from "./backend/types.js";
30
42
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
@@ -4950,13 +4962,11 @@ export class FleetManager {
4950
4962
  if (msg.adapterId)
4951
4963
  this.bindInstanceAdapter(generalInstance, msg.adapterId, true);
4952
4964
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
4953
- // React immediately — before any other API calls. Use the adapter BOUND to
4954
- // the instance (not whichever same-guild bot received the event first) so
4955
- // exactly the owning bot reacts — no duplicate 👀 from a sibling bot.
4965
+ // React immediately — before any other API calls — through the status
4966
+ // path, so a later delivery status replaces (not stacks onto) this
4967
+ // acknowledgement.
4956
4968
  if (msg.chatId && msg.messageId) {
4957
- const reactAdapter = this.getAdapterForInstance(generalInstance) ?? inboundAdapter;
4958
- reactAdapter.react(msg.threadId ?? msg.chatId, msg.messageId, "👀")
4959
- .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
4969
+ this.reactMessageStatus(generalInstance, msg.chatId, msg.messageId, "👀", msg.threadId || undefined);
4960
4970
  }
4961
4971
  this.warnIfRateLimited(generalInstance, msg);
4962
4972
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
@@ -5044,13 +5054,11 @@ export class FleetManager {
5044
5054
  if (msg.adapterId)
5045
5055
  this.bindInstanceAdapter(instanceName, msg.adapterId, true);
5046
5056
  const inboundAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
5047
- // React immediately — before any other Discord API calls. Use the adapter
5048
- // BOUND to the instance (not whichever same-guild bot received the event
5049
- // first) so exactly the owning bot reacts — no duplicate 👀 from a sibling.
5057
+ // React immediately — before any other Discord API calls — through the
5058
+ // status path, so a later delivery status replaces (not stacks onto) this
5059
+ // acknowledgement. Same bound-adapter routing as the status path itself.
5050
5060
  if (msg.chatId && msg.messageId) {
5051
- const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
5052
- reactAdapter.react(this.reactTarget(msg), msg.messageId, "👀")
5053
- .catch(e => this.logger.debug({ err: e.message }, "Auto-react failed"));
5061
+ this.reactMessageStatus(instanceName, msg.chatId, msg.messageId, "👀", msg.threadId || undefined);
5054
5062
  }
5055
5063
  // These may hit Discord API (topic icon, archive) — do after react
5056
5064
  if (this.topicArchiver.isArchived(threadId)) {
@@ -5089,8 +5097,10 @@ export class FleetManager {
5089
5097
  catch (err) {
5090
5098
  this.logger.warn({ err: err.message, instanceName }, "Wake/delivery failed");
5091
5099
  if (msg.chatId && msg.messageId) {
5092
- const reactAdapter = this.getAdapterForInstance(instanceName) ?? inboundAdapter;
5093
- reactAdapter.react(this.reactTarget(msg), msg.messageId, "❌").catch(() => { });
5100
+ // Reconciled status path (not a bare add): a retry that later
5101
+ // succeeds replaces this ❌ with ✅ instead of leaving both. Chat and
5102
+ // thread travel separately so Telegram addresses the supergroup.
5103
+ this.finishDeliveryStatus(instanceName, msg.chatId, msg.messageId, "❌", msg.threadId || undefined);
5094
5104
  }
5095
5105
  return;
5096
5106
  }
@@ -6503,17 +6513,89 @@ export class FleetManager {
6503
6513
  // Pausing stops I/O but retains the last observed limits for status views.
6504
6514
  this.statuslineWatcher.unwatch(name, true);
6505
6515
  }
6506
- reactMessageStatus(instanceName, chatId, messageId, emoji) {
6516
+ /**
6517
+ * Last delivery status applied per bot+message. Unbounded by design: any cap
6518
+ * reintroduces the hole where a forgotten ❌ can never be cleared, and an
6519
+ * entry is two short strings (id pair plus one emoji) — thousands of
6520
+ * status-reacted messages cost kilobytes. A bot restart loses it, degrading
6521
+ * to a plain add (never worse than the old behaviour).
6522
+ */
6523
+ lastStatusEmoji = new Map();
6524
+ /**
6525
+ * One in-flight status update per bot+message: updates run strictly in call
6526
+ * order, so a delayed ❌ add can never land after a newer ✅'s removal —
6527
+ * the final state is always the latest call. Entries delete themselves when
6528
+ * the chain drains, so idle messages hold no memory.
6529
+ */
6530
+ deliveryStatusChains = new Map();
6531
+ reactMessageStatus(instanceName, chatId, messageId, emoji, threadId) {
6507
6532
  // React via the adapter BOUND to this instance — NOT the first discord world.
6508
6533
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
6509
6534
  // (bound bot) and the delivery/confirm reactions (some other bot) come from
6510
6535
  // different bots, leaving a duplicate 👀 that never turns into ✅.
6511
6536
  const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
6512
- // Status reactions are Discord-only (TG/others use the inbound react path).
6513
- if (!adapter || adapter.type !== "discord")
6537
+ if (!adapter)
6514
6538
  return;
6515
- adapter.react(chatId, messageId, emoji)
6516
- .catch(e => this.logger.debug({ err: e.message }, "Message status react failed"));
6539
+ const statusEmoji = adapter instanceof TelegramAdapter
6540
+ ? TELEGRAM_DELIVERY_STATUS_REACTIONS.get(emoji)
6541
+ : emoji;
6542
+ // Telegram has a deliberately smaller status vocabulary, and every mapped
6543
+ // value is accepted by ReactionTypeEmoji. Unknown statuses stay a no-op.
6544
+ if (!statusEmoji)
6545
+ return;
6546
+ // Bot-scoped: sibling bots reacting on the same message must not clear
6547
+ // each other's state — every removal below targets this adapter's own
6548
+ // reactions (@me on Discord, the bot's list on Telegram).
6549
+ const adapterId = typeof adapter.id === "string"
6550
+ ? adapter.id : "?";
6551
+ const key = `${adapterId}:${chatId}:${threadId ?? ""}:${messageId}`;
6552
+ this.queueDeliveryStatusReaction(adapter, key, chatId, messageId, statusEmoji, threadId);
6553
+ }
6554
+ /**
6555
+ * Apply a terminal delivery verdict. Telegram maps ✅ to 👀 and ❌ to 👎 in
6556
+ * reactMessageStatus; Discord continues to show the verdict emoji directly.
6557
+ */
6558
+ finishDeliveryStatus(instanceName, chatId, messageId, emoji, threadId) {
6559
+ this.reactMessageStatus(instanceName, chatId, messageId, emoji, threadId);
6560
+ }
6561
+ queueDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId) {
6562
+ const prev = this.deliveryStatusChains.get(key) ?? Promise.resolve();
6563
+ const run = prev.then(() => this.applyDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId));
6564
+ this.deliveryStatusChains.set(key, run);
6565
+ void run.then(() => { if (this.deliveryStatusChains.get(key) === run)
6566
+ this.deliveryStatusChains.delete(key); }, () => { if (this.deliveryStatusChains.get(key) === run)
6567
+ this.deliveryStatusChains.delete(key); });
6568
+ }
6569
+ async applyDeliveryStatusReaction(adapter, key, chatId, messageId, emoji, threadId) {
6570
+ try {
6571
+ const target = adapter instanceof TelegramAdapter ? chatId : (threadId ?? chatId);
6572
+ const prev = this.lastStatusEmoji.get(key);
6573
+ if (emoji == null) {
6574
+ if (prev && adapter.unreact) {
6575
+ await adapter.unreact(target, messageId, prev, threadId);
6576
+ this.lastStatusEmoji.delete(key);
6577
+ }
6578
+ return;
6579
+ }
6580
+ // A Telegram status may map multiple lifecycle states to the same
6581
+ // reaction. Avoid resending it: an agent may have replaced the one
6582
+ // reaction slot with its own 👍 between delivered and confirmed.
6583
+ if (prev === emoji)
6584
+ return;
6585
+ // Thread-aware adapters (Discord) react where the thread is; Telegram
6586
+ // addresses the supergroup chat and ignores the thread part.
6587
+ // Discord needs the old reaction removed before the new one is added.
6588
+ // Telegram's setMessageReaction replaces the bot's one current reaction.
6589
+ if (prev && prev !== emoji && adapter.unreact && !(adapter instanceof TelegramAdapter)) {
6590
+ // Best effort: a failed removal must not block the new status.
6591
+ await adapter.unreact(target, messageId, prev, threadId).catch(e => this.logger.debug({ err: e.message }, "Delivery status reaction removal failed"));
6592
+ }
6593
+ await adapter.react(target, messageId, emoji, threadId);
6594
+ this.lastStatusEmoji.set(key, emoji);
6595
+ }
6596
+ catch (e) {
6597
+ this.logger.debug({ err: e.message }, "Message status react failed");
6598
+ }
6517
6599
  }
6518
6600
  // ── Model failover ──────────────────────────────────────────────────────
6519
6601
  static FAILOVER_TRIGGER_PCT = 90;
@@ -8186,15 +8268,6 @@ export class FleetManager {
8186
8268
  this.retireButton(e);
8187
8269
  }
8188
8270
  }
8189
- /**
8190
- * Reaction target chat id. Telegram reactions key on the supergroup chat_id
8191
- * (the topic thread is NOT a chat_id), so a forum-topic message must react on
8192
- * msg.chatId — reacting on threadId silently fails. Discord reactions key on
8193
- * the channel/thread id.
8194
- */
8195
- reactTarget(msg) {
8196
- return msg.source === "telegram" ? msg.chatId : (msg.threadId ?? msg.chatId);
8197
- }
8198
8271
  /** Remember the user message just delivered, so we can react ✅ when done. */
8199
8272
  trackInboundMsg(instanceName, msg) {
8200
8273
  if (!msg.chatId || !msg.messageId)