@animalabs/agent-framework 0.15.0 → 0.17.0

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 (100) hide show
  1. package/README.md +22 -1
  2. package/dist/src/agent.d.ts +32 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +55 -0
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts +9 -0
  7. package/dist/src/api/server.d.ts.map +1 -1
  8. package/dist/src/api/server.js +156 -0
  9. package/dist/src/api/server.js.map +1 -1
  10. package/dist/src/api/types.d.ts +15 -0
  11. package/dist/src/api/types.d.ts.map +1 -1
  12. package/dist/src/framework.d.ts +423 -3
  13. package/dist/src/framework.d.ts.map +1 -1
  14. package/dist/src/framework.js +1857 -75
  15. package/dist/src/framework.js.map +1 -1
  16. package/dist/src/gate/event-gate.d.ts +16 -0
  17. package/dist/src/gate/event-gate.d.ts.map +1 -1
  18. package/dist/src/gate/event-gate.js +41 -3
  19. package/dist/src/gate/event-gate.js.map +1 -1
  20. package/dist/src/index.d.ts +4 -1
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +3 -1
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/mcpl/channel-registry.d.ts +172 -0
  25. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  26. package/dist/src/mcpl/channel-registry.js +444 -2
  27. package/dist/src/mcpl/channel-registry.js.map +1 -1
  28. package/dist/src/mcpl/server-connection.d.ts +9 -0
  29. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  30. package/dist/src/mcpl/server-connection.js +71 -13
  31. package/dist/src/mcpl/server-connection.js.map +1 -1
  32. package/dist/src/modules/history/index.d.ts +89 -13
  33. package/dist/src/modules/history/index.d.ts.map +1 -1
  34. package/dist/src/modules/history/index.js +655 -24
  35. package/dist/src/modules/history/index.js.map +1 -1
  36. package/dist/src/operator-log.d.ts +78 -0
  37. package/dist/src/operator-log.d.ts.map +1 -0
  38. package/dist/src/operator-log.js +102 -0
  39. package/dist/src/operator-log.js.map +1 -0
  40. package/dist/src/recovery/discord-awareness-outbox.d.ts +10 -0
  41. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  42. package/dist/src/recovery/discord-awareness-outbox.js +20 -0
  43. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  44. package/dist/src/types/framework.d.ts +56 -0
  45. package/dist/src/types/framework.d.ts.map +1 -1
  46. package/dist/src/types/trace.d.ts +36 -0
  47. package/dist/src/types/trace.d.ts.map +1 -1
  48. package/dist/test/agent-runtime-settings.test.js +199 -1
  49. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  50. package/dist/test/api-admin-gate.test.d.ts +2 -0
  51. package/dist/test/api-admin-gate.test.d.ts.map +1 -0
  52. package/dist/test/api-admin-gate.test.js +116 -0
  53. package/dist/test/api-admin-gate.test.js.map +1 -0
  54. package/dist/test/channel-label-history.test.d.ts +2 -0
  55. package/dist/test/channel-label-history.test.d.ts.map +1 -0
  56. package/dist/test/channel-label-history.test.js +424 -0
  57. package/dist/test/channel-label-history.test.js.map +1 -0
  58. package/dist/test/discord-awareness-outbox.test.js +16 -0
  59. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  60. package/dist/test/event-gate.test.js +44 -0
  61. package/dist/test/event-gate.test.js.map +1 -1
  62. package/dist/test/helpers/quiesce-crash-child.d.ts +2 -0
  63. package/dist/test/helpers/quiesce-crash-child.d.ts.map +1 -0
  64. package/dist/test/helpers/quiesce-crash-child.js +156 -0
  65. package/dist/test/helpers/quiesce-crash-child.js.map +1 -0
  66. package/dist/test/history-module-overview.test.d.ts +2 -0
  67. package/dist/test/history-module-overview.test.d.ts.map +1 -0
  68. package/dist/test/history-module-overview.test.js +711 -0
  69. package/dist/test/history-module-overview.test.js.map +1 -0
  70. package/dist/test/history-module.test.js +146 -0
  71. package/dist/test/history-module.test.js.map +1 -1
  72. package/dist/test/host-quiesce.test.d.ts +2 -0
  73. package/dist/test/host-quiesce.test.d.ts.map +1 -0
  74. package/dist/test/host-quiesce.test.js +863 -0
  75. package/dist/test/host-quiesce.test.js.map +1 -0
  76. package/dist/test/liveness-watchdog.test.js +18 -2
  77. package/dist/test/liveness-watchdog.test.js.map +1 -1
  78. package/dist/test/operator-surgery.test.d.ts +2 -0
  79. package/dist/test/operator-surgery.test.d.ts.map +1 -0
  80. package/dist/test/operator-surgery.test.js +398 -0
  81. package/dist/test/operator-surgery.test.js.map +1 -0
  82. package/dist/test/provider-acceleration-cooldown.test.js +145 -0
  83. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
  84. package/dist/test/puppet-tool-call.test.js +5 -0
  85. package/dist/test/puppet-tool-call.test.js.map +1 -1
  86. package/dist/tsconfig.tsbuildinfo +1 -1
  87. package/package.json +2 -2
  88. package/src/agent.ts +69 -0
  89. package/src/api/server.ts +159 -2
  90. package/src/api/types.ts +15 -0
  91. package/src/framework.ts +2155 -96
  92. package/src/gate/event-gate.ts +47 -4
  93. package/src/index.ts +5 -1
  94. package/src/mcpl/channel-registry.ts +495 -2
  95. package/src/mcpl/server-connection.ts +73 -13
  96. package/src/modules/history/index.ts +689 -25
  97. package/src/operator-log.ts +132 -0
  98. package/src/recovery/discord-awareness-outbox.ts +19 -0
  99. package/src/types/framework.ts +63 -0
  100. package/src/types/trace.ts +42 -0
@@ -45,6 +45,18 @@ import { CapabilityGrant } from './capability-grant.js';
45
45
  const TYPING_INTERVAL_MS = 7_000;
46
46
  const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
47
47
 
48
+ /**
49
+ * Durable "which label did this channelId have, each time we saw it" log.
50
+ * `resolveProseTarget()` resolves a label to an id only for channels
51
+ * currently in the live `channels` map — fine for normal addressing, but
52
+ * useless for browsing history on a channel the bot has since disconnected
53
+ * from (or that didn't survive a restart). This log lets that lookup survive
54
+ * a disconnect/restart by replaying the most recent label sighting per
55
+ * channelId. See `labelHistory`, `appendLabelSighting`, and
56
+ * `resolveProseTargetDurable`.
57
+ */
58
+ const CHANNEL_LABEL_HISTORY_LOG_ID = 'mcpl/channel-label-history';
59
+
48
60
  type DesiredChannelState = 'open' | 'closed' | 'tuned-out';
49
61
 
50
62
  /**
@@ -97,6 +109,79 @@ interface ChannelLifecycleEvent {
97
109
  acknowledgment?: string;
98
110
  }
99
111
 
112
+ /** One append-log record in `CHANNEL_LABEL_HISTORY_LOG_ID`: "at `ts`, this
113
+ * channelId's label was observed to be `label`". See `labelHistory`. */
114
+ interface ChannelLabelSightingEvent {
115
+ channelId: string;
116
+ label: string;
117
+ ts: number;
118
+ /** DM recipient id (e.g. a Discord snowflake), when the descriptor's
119
+ * metadata carried one — persisted so a `<@id>` mention form can still
120
+ * resolve a DM's channelId after a restart, the same way
121
+ * `resolveProseTarget()`'s live `dmMeta().recipientId` matching does. */
122
+ recipientId?: string;
123
+ /** DM recipient's actual username, when the descriptor's metadata
124
+ * carried one — persisted because it can differ from the descriptor's
125
+ * display `label` (e.g. label "DM: Tess" but recipientName
126
+ * "antra_tessera"), and `resolveProseTarget()`'s live matching prefers
127
+ * it over the label for exactly that reason. Without this, an `@name`
128
+ * lookup after a restart could only ever match the label text, never
129
+ * the actual username an agent would naturally type. */
130
+ recipientName?: string;
131
+ /** True when the descriptor's metadata explicitly classified this
132
+ * channel as a DM (`channelType === 'dm'`) at sighting time — persisted
133
+ * because `resolveProseTarget()`'s live classification checks this
134
+ * metadata field FIRST, before any id-shape or label-prefix convention,
135
+ * so a DM whose id/label don't follow the usual `:dm:`/`DM: ` shape
136
+ * still needs a durable way to be recognized as a DM at all once
137
+ * disconnected. Only ever recorded `true` (an explicit positive
138
+ * classification) — a channel with no signal either way is left
139
+ * `undefined`, not asserted `false`, so classification always falls
140
+ * back to id/label-shape heuristics rather than durably asserting a
141
+ * negative for the (overwhelmingly common) case where this metadata
142
+ * was simply never sent. */
143
+ isDm?: boolean;
144
+ }
145
+
146
+ /** Extract a DM's identity/classification fields from a descriptor's
147
+ * metadata, if present — mirrors `resolveProseTarget()`'s own local
148
+ * `dmMeta()` read (`recipientId`, `recipientName`, `channelType`). Used
149
+ * to durably persist them alongside a label sighting (see
150
+ * `ChannelLabelSightingEvent`'s corresponding fields), captured at the
151
+ * SAME point (sighting time) rather than re-derived later from id/label
152
+ * shape, which is exactly what the live resolver does NOT do either. */
153
+ function extractDmMeta(metadata: Record<string, unknown> | undefined): {
154
+ recipientId?: string;
155
+ recipientName?: string;
156
+ isDm?: boolean;
157
+ } {
158
+ const recipientId = typeof metadata?.recipientId === 'string' ? metadata.recipientId : undefined;
159
+ const recipientName = typeof metadata?.recipientName === 'string' ? metadata.recipientName : undefined;
160
+ const isDm = metadata?.channelType === 'dm' ? true : undefined;
161
+ return { recipientId, recipientName, isDm };
162
+ }
163
+
164
+ /**
165
+ * Case-insensitive channel-label comparison key: strips a leading '#' and
166
+ * lowercases. Mirrors the normalization `resolveProseTarget()` applies to
167
+ * its live-channel label matches (see the local `norm` there) — factored out
168
+ * so `resolveLabelFromHistory()` recognizes the same spellings without
169
+ * duplicating (and risking drift from) the live matching rules.
170
+ */
171
+ function normalizeChannelLabel(s: string): string {
172
+ return s.replace(/^#/, '').toLowerCase();
173
+ }
174
+
175
+ /**
176
+ * Same as `normalizeChannelLabel`, but also strips a trailing parenthetical
177
+ * guild/server suffix (e.g. "#fable (antra's server)" -> "fable") so a bare
178
+ * channel name matches the disambiguated label agents often omit. Mirrors
179
+ * `resolveProseTarget()`'s local `nameOf` helper.
180
+ */
181
+ function normalizeChannelLabelName(label: string): string {
182
+ return normalizeChannelLabel(label.replace(/\s*\([^)]*\)\s*$/, ''));
183
+ }
184
+
100
185
  function shallowEqualRecord(
101
186
  a: Record<string, unknown> | undefined,
102
187
  b: Record<string, unknown> | undefined,
@@ -460,6 +545,56 @@ export class ChannelRegistry {
460
545
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
461
546
  private channels = new Map<string, ChannelEntry>();
462
547
 
548
+ /**
549
+ * Most-recently-known label per channelId, replayed from
550
+ * `CHANNEL_LABEL_HISTORY_LOG_ID` (last sighting wins) and kept current by
551
+ * `appendLabelSighting()`. Unlike `channels`, this survives disconnect and
552
+ * restart — it's what lets `resolveProseTargetDurable()` resolve a label
553
+ * for a channel that's no longer live. Keyed by bare channelId, not
554
+ * `{serverId}:{channelId}` — the label history exists to answer "what id
555
+ * did this label refer to", independent of which server it came through.
556
+ */
557
+ private labelHistory = new Map<string, string>();
558
+
559
+ /**
560
+ * EVERY distinct label a channelId has ever been durably seen with (not
561
+ * just the latest) — additive alongside `labelHistory`, populated by the
562
+ * same replay-at-boot loop and the same `appendLabelSighting()` call.
563
+ * `labelHistory` alone can only resolve a channel's CURRENT/latest name;
564
+ * this is what lets `resolveLabelFromHistory()` find a channel by a label
565
+ * it used to have before a rename — the exact case this whole durable log
566
+ * exists for (finding an old/renamed channel while browsing history).
567
+ */
568
+ private allLabelsSeen = new Map<string, Set<string>>();
569
+
570
+ /**
571
+ * DM recipientId per channelId, durably replayed alongside `labelHistory`
572
+ * — the piece a `<@id>` mention-form lookup needs that `allLabelsSeen`'s
573
+ * label strings alone can't provide. See `resolveLabelFromHistory()`'s
574
+ * DM-aware arm.
575
+ */
576
+ private dmRecipientIds = new Map<string, string>();
577
+
578
+ /**
579
+ * DM recipient's actual username per channelId — can differ from the
580
+ * channel's display label (`resolveProseTarget()`'s live DM matching
581
+ * prefers this over the label for exactly that reason). See
582
+ * `resolveDmFromHistory()`.
583
+ */
584
+ private dmRecipientNames = new Map<string, string>();
585
+
586
+ /**
587
+ * channelId -> true, for every channel EXPLICITLY classified as a DM via
588
+ * `metadata.channelType === 'dm'` at some sighting — an id/label-shape-
589
+ * independent classification signal, the same one `resolveProseTarget()`
590
+ * checks live. Only ever holds `true`; a channel with no explicit
591
+ * classification is simply absent (not `false`), so `isDmChannelId()`
592
+ * falls back to id/label-shape heuristics for it rather than durably
593
+ * asserting a negative for the common case where this metadata was never
594
+ * sent at all.
595
+ */
596
+ private dmClassified = new Map<string, true>();
597
+
463
598
  /** Most recent incoming channel ID — used for speech routing / default publish. */
464
599
  private defaultPublishChannel: string | null = null;
465
600
 
@@ -517,6 +652,7 @@ export class ChannelRegistry {
517
652
  this.activeChannelResolver = options?.activeChannelResolver;
518
653
  this.store = options?.store;
519
654
  this.initializeLifecycleStore();
655
+ this.initializeLabelHistoryStore();
520
656
  }
521
657
 
522
658
  /**
@@ -562,6 +698,7 @@ export class ChannelRegistry {
562
698
  descriptor: channel,
563
699
  open: false,
564
700
  });
701
+ this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
565
702
  registeredIds.push(channel.id);
566
703
  results.push({ id: channel.id, accepted: true });
567
704
  }
@@ -631,6 +768,7 @@ export class ChannelRegistry {
631
768
  continue;
632
769
  }
633
770
  existing.descriptor = channel;
771
+ this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
634
772
  addedResults.push({ id: channel.id, accepted: true });
635
773
  }
636
774
  }
@@ -650,6 +788,7 @@ export class ChannelRegistry {
650
788
  descriptor: channel,
651
789
  open: false,
652
790
  });
791
+ this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
653
792
  addedResults.push({ id: channel.id, accepted: true });
654
793
  accepted.push(channel);
655
794
  }
@@ -836,6 +975,12 @@ export class ChannelRegistry {
836
975
  if (extraMetadata) {
837
976
  existing.descriptor.metadata = { ...existing.descriptor.metadata, ...extraMetadata };
838
977
  }
978
+ // The channel now has a real label, not just the bare id — durable.
979
+ this.appendLabelSighting(
980
+ existing.descriptor.id,
981
+ existing.descriptor.label,
982
+ extractDmMeta(existing.descriptor.metadata),
983
+ );
839
984
  }
840
985
  return;
841
986
  }
@@ -852,6 +997,19 @@ export class ChannelRegistry {
852
997
  },
853
998
  open: false,
854
999
  });
1000
+ // Only persist a REAL label durably. Falling back to the bare
1001
+ // channelId as a placeholder (no server-supplied label at all — e.g. a
1002
+ // Discord DM push missing both channelName and an author name,
1003
+ // framework.ts's derivePushEventChannel) must never overwrite a real
1004
+ // label already on file from a previous boot: after a restart the live
1005
+ // `channels` map starts empty, so THIS is the very branch a
1006
+ // label-less post-restart event takes — recording the placeholder here
1007
+ // would clobber the good label initializeLabelHistoryStore() just
1008
+ // replayed, defeating the entire point of the durable log. The live
1009
+ // descriptor above still carries the placeholder for in-process
1010
+ // routing; only the durable write is skipped when there's no real
1011
+ // label to record.
1012
+ if (label) this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
855
1013
  this.emitTraceFn({
856
1014
  type: 'mcpl:channel-lazy-registered',
857
1015
  serverId,
@@ -1228,6 +1386,341 @@ export class ChannelRegistry {
1228
1386
  this.store?.appendToStateJson(CHANNEL_LIFECYCLE_LOG_ID, event);
1229
1387
  }
1230
1388
 
1389
+ // ==========================================================================
1390
+ // Private: Durable channel-label history (disconnected-channel resolution)
1391
+ // ==========================================================================
1392
+
1393
+ /**
1394
+ * Replay `CHANNEL_LABEL_HISTORY_LOG_ID` into `labelHistory`. Same
1395
+ * construction-time timing as `initializeLifecycleStore()`, and the same
1396
+ * register-if-missing / swallow-"already exists" pattern. The log is
1397
+ * chronological, so folding forward and letting each record overwrite the
1398
+ * map entry for its channelId naturally keeps only the latest sighting.
1399
+ */
1400
+ private initializeLabelHistoryStore(): void {
1401
+ if (!this.store) return;
1402
+
1403
+ try {
1404
+ this.store.registerState({ id: CHANNEL_LABEL_HISTORY_LOG_ID, strategy: 'append_log' });
1405
+ } catch (error) {
1406
+ if (!(error instanceof Error) || !error.message.includes('State already exists')) {
1407
+ throw error;
1408
+ }
1409
+ }
1410
+
1411
+ const raw = this.store.getStateJson(CHANNEL_LABEL_HISTORY_LOG_ID);
1412
+ if (!Array.isArray(raw)) return;
1413
+
1414
+ for (const item of raw) {
1415
+ if (!item || typeof item !== 'object') continue;
1416
+ const event = item as Partial<ChannelLabelSightingEvent>;
1417
+ if (typeof event.channelId !== 'string' || typeof event.label !== 'string' || !event.label) continue;
1418
+ this.labelHistory.set(event.channelId, event.label);
1419
+ let seen = this.allLabelsSeen.get(event.channelId);
1420
+ if (!seen) {
1421
+ seen = new Set();
1422
+ this.allLabelsSeen.set(event.channelId, seen);
1423
+ }
1424
+ seen.add(event.label);
1425
+ if (typeof event.recipientId === 'string' && event.recipientId) {
1426
+ this.dmRecipientIds.set(event.channelId, event.recipientId);
1427
+ }
1428
+ if (typeof event.recipientName === 'string' && event.recipientName) {
1429
+ this.dmRecipientNames.set(event.channelId, event.recipientName);
1430
+ }
1431
+ if (event.isDm === true) {
1432
+ this.dmClassified.set(event.channelId, true);
1433
+ }
1434
+ }
1435
+ }
1436
+
1437
+ /**
1438
+ * Record that `channelId` was most recently seen with `label` (and,
1439
+ * optionally, DM identity/classification fields). Called from every
1440
+ * place a `ChannelDescriptor` is set into the live `channels` map
1441
+ * (`handleRegister`, `handleChanged`'s update/add branches,
1442
+ * `ensureChannelRegistered`) so the label survives disconnect and restart
1443
+ * for `resolveLabelFromHistory()` / `resolveProseTargetDurable()`.
1444
+ *
1445
+ * Guarded like `setDesiredState()`: a reconnect or re-registration that
1446
+ * reports the same label AND the same dmMeta fields as last time is a
1447
+ * no-op, so the append log doesn't grow without bound on every
1448
+ * boot/resubscribe — but a later call that newly supplies (or changes)
1449
+ * any dmMeta field for an already-known label (label unchanged) still
1450
+ * records, since that's genuinely new durable information, not a no-op
1451
+ * resighting. `isDm` only ever compares against a possible narrowing:
1452
+ * once durably `true`, a later sighting without `channelType==='dm'`
1453
+ * metadata (i.e. `dmMeta.isDm === undefined`, NOT `false` — see
1454
+ * `extractDmMeta`) is simply silent on the question, not a "no longer a
1455
+ * DM" downgrade, so it never counts as a change on its own.
1456
+ *
1457
+ * `label` is `string | undefined` (not just `string`) even though
1458
+ * `ChannelDescriptor.label` is typed `string`: that typing is a
1459
+ * compile-time-only assertion over untrusted wire data from an MCPL
1460
+ * server, and only `channel.id` is runtime-validated on the way in
1461
+ * (`handleRegister`/`handleChanged`). A missing/undefined label is
1462
+ * dropped rather than recorded — durable garbage here would later throw
1463
+ * inside `resolveLabelFromHistory()`'s normalization, which every
1464
+ * `HistoryModule` tool call passes through unguarded via
1465
+ * `resolveProseTargetDurable()`.
1466
+ */
1467
+ private appendLabelSighting(
1468
+ channelId: string,
1469
+ label: string | undefined,
1470
+ dmMeta: { recipientId?: string; recipientName?: string; isDm?: boolean } = {},
1471
+ ): void {
1472
+ if (typeof label !== 'string' || !label) return;
1473
+ const { recipientId, recipientName, isDm } = dmMeta;
1474
+ // Never let a bare-id placeholder (ensureChannelRegistered's fallback
1475
+ // `label ?? channelId` when no real label was ever supplied — e.g. a
1476
+ // Discord DM push missing both channelName and an author name) durably
1477
+ // overwrite a real label already on file. This is exactly the restart
1478
+ // scenario the durable log exists to survive: the live `channels` map
1479
+ // is empty after a restart, so the next label-less event would
1480
+ // otherwise take the "first sighting" path and clobber a good label
1481
+ // the replay above just restored. (Belt-and-braces alongside
1482
+ // ensureChannelRegistered's own guard, which should stop this before
1483
+ // it ever gets here — see there for the primary fix.)
1484
+ if (label === channelId && this.labelHistory.has(channelId)) return;
1485
+ const labelUnchanged = this.labelHistory.get(channelId) === label;
1486
+ const recipientIdUnchanged = recipientId === undefined || recipientId === this.dmRecipientIds.get(channelId);
1487
+ const recipientNameUnchanged = recipientName === undefined || recipientName === this.dmRecipientNames.get(channelId);
1488
+ const isDmUnchanged = isDm === undefined || this.dmClassified.get(channelId) === true;
1489
+ if (labelUnchanged && recipientIdUnchanged && recipientNameUnchanged && isDmUnchanged) return;
1490
+ this.labelHistory.set(channelId, label);
1491
+ let seen = this.allLabelsSeen.get(channelId);
1492
+ if (!seen) {
1493
+ seen = new Set();
1494
+ this.allLabelsSeen.set(channelId, seen);
1495
+ }
1496
+ seen.add(label);
1497
+ if (recipientId) this.dmRecipientIds.set(channelId, recipientId);
1498
+ if (recipientName) this.dmRecipientNames.set(channelId, recipientName);
1499
+ if (isDm) this.dmClassified.set(channelId, true);
1500
+ this.store?.appendToStateJson(CHANNEL_LABEL_HISTORY_LOG_ID, {
1501
+ channelId,
1502
+ label,
1503
+ ts: Date.now(),
1504
+ ...(recipientId ? { recipientId } : {}),
1505
+ ...(recipientName ? { recipientName } : {}),
1506
+ ...(isDm ? { isDm } : {}),
1507
+ } satisfies ChannelLabelSightingEvent);
1508
+ }
1509
+
1510
+ /**
1511
+ * Look up a label spec against label *history* rather than the live
1512
+ * `channels` map — the fallback path for a channel the bot isn't
1513
+ * currently connected to. Applies the same normalization
1514
+ * `resolveProseTarget()` uses for its live label/name matches
1515
+ * (`normalizeChannelLabel` / `normalizeChannelLabelName`), plus a raw-id
1516
+ * fast path (mirroring `resolveProseTarget()`'s own raw-id acceptance).
1517
+ *
1518
+ * Matches against `allLabelsSeen` — EVERY label a channel has ever had —
1519
+ * not just its current/latest one in `labelHistory`, so a channel renamed
1520
+ * A -> B -> A is still resolvable by a name from BEFORE the most recent
1521
+ * rename (the point of a history-browsing tool is finding things by what
1522
+ * they used to be called). Ambiguity is genuine here: if the same
1523
+ * normalized spec matches historical labels belonging to two DIFFERENT
1524
+ * channelIds (an actual rename collision, not just the same channel seen
1525
+ * twice), that's a real ambiguity, not a false positive from re-scanning
1526
+ * one channel's own label list — `byLabel`/`byName` tracking below
1527
+ * already only flags ambiguous when the matched channelId itself differs.
1528
+ *
1529
+ * Returns undefined — rather than raising an ambiguity error — when a
1530
+ * normalized spec matches more than one distinct channelId in history;
1531
+ * callers fall back to `resolveProseTarget()`'s original error in that
1532
+ * case via `resolveProseTargetDurable()`.
1533
+ */
1534
+ private resolveLabelFromHistory(spec: string): string | undefined {
1535
+ const trimmed = spec.trim();
1536
+ if (!trimmed) return undefined;
1537
+
1538
+ // Fast path: spec is already a channelId we have label history for.
1539
+ if (this.labelHistory.has(trimmed)) return trimmed;
1540
+
1541
+ // DM-aware arm, mirroring resolveProseTarget()'s own `@name` / `<@id>`
1542
+ // handling (see there) — but over durable history instead of the live
1543
+ // `channels` map, so a disconnected/post-restart DM stays addressable
1544
+ // by the natural forms an agent would type, not just the exact stored
1545
+ // label string. Tried BEFORE the generic byLabel/byName loop below
1546
+ // because a bare `@antra` would otherwise also partially match via
1547
+ // normalizeChannelLabel's '#'-only stripping (which does nothing for a
1548
+ // leading '@') and fail confusingly instead of going through DM rules.
1549
+ const mention = /^<@!?(\d+)>$/.exec(trimmed);
1550
+ if (trimmed.startsWith('@') || mention) {
1551
+ return this.resolveDmFromHistory(trimmed, mention);
1552
+ }
1553
+
1554
+ const normSpec = normalizeChannelLabel(trimmed);
1555
+ let byLabel: string | undefined;
1556
+ let byLabelAmbiguous = false;
1557
+ let byName: string | undefined;
1558
+ let byNameAmbiguous = false;
1559
+
1560
+ for (const [channelId, labels] of this.allLabelsSeen) {
1561
+ for (const label of labels) {
1562
+ // `?? ''` is belt-and-braces: appendLabelSighting/replay already
1563
+ // refuse to store a falsy label, so this should never see one, but
1564
+ // normalizeChannelLabel has no internal guard of its own (unlike
1565
+ // resolveProseTarget's `e.descriptor.label ?? ''`), and this is
1566
+ // exactly the kind of one-bad-record-poisons-every-future-call path
1567
+ // that caused finding #2.
1568
+ const normLabel = normalizeChannelLabel(label ?? '');
1569
+ const normName = normalizeChannelLabelName(label ?? '');
1570
+ if (normLabel === normSpec) {
1571
+ if (byLabel !== undefined && byLabel !== channelId) byLabelAmbiguous = true;
1572
+ byLabel = channelId;
1573
+ }
1574
+ if (normName === normSpec) {
1575
+ if (byName !== undefined && byName !== channelId) byNameAmbiguous = true;
1576
+ byName = channelId;
1577
+ }
1578
+ }
1579
+ }
1580
+
1581
+ if (byLabel !== undefined && !byLabelAmbiguous) return byLabel;
1582
+ if (byName !== undefined && !byNameAmbiguous) return byName;
1583
+ return undefined;
1584
+ }
1585
+
1586
+ /**
1587
+ * A channel counts as a DM (for the purposes of durable-history lookup)
1588
+ * if: it was EXPLICITLY classified as one via `metadata.channelType ===
1589
+ * 'dm'` at some sighting (`dmClassified` — the same classification
1590
+ * signal `resolveProseTarget()`'s live `isDmEntry()` checks FIRST, before
1591
+ * any naming convention, so an id/label that don't follow the usual
1592
+ * `:dm:`/`DM: ` shape still get recognized correctly — this is what
1593
+ * `private-room-42` labeled just `Tess` needs); OR, as a fallback for
1594
+ * descriptors that never carried that metadata at all, its id shape says
1595
+ * so, or any label it has ever carried says so.
1596
+ */
1597
+ private isDmChannelId(channelId: string): boolean {
1598
+ if (this.dmClassified.get(channelId) === true) return true;
1599
+ if (channelId.includes(':dm:')) return true;
1600
+ const labels = this.allLabelsSeen.get(channelId);
1601
+ if (!labels) return false;
1602
+ for (const label of labels) {
1603
+ if (label.toLowerCase().startsWith('dm: ')) return true;
1604
+ }
1605
+ return false;
1606
+ }
1607
+
1608
+ /**
1609
+ * Durable-history counterpart to `resolveProseTarget()`'s DM-matching arm
1610
+ * (see there for the live version this mirrors). Handles the two forms
1611
+ * `resolveProseTarget()` supports: a `<@id>` mention (matched against
1612
+ * `dmRecipientIds`, persisted alongside a label sighting specifically for
1613
+ * this) and a bare `@name` (matched against EITHER the persisted
1614
+ * `dmRecipientNames` — the DM's actual username, which the live resolver
1615
+ * prefers and which can differ from the display label, e.g. label
1616
+ * "DM: Tess" but username "antra_tessera" — OR the `DM: `-stripped form
1617
+ * of each DM channel's CURRENT label in `labelHistory`, as a fallback for
1618
+ * descriptors that never carried a recipientName; historical DM labels
1619
+ * aren't name-matched here the way non-DM labels are in
1620
+ * `resolveLabelFromHistory`'s main loop, since a DM's label is a person's
1621
+ * name, not a channel name subject to the same kind of rename).
1622
+ *
1623
+ * Returns undefined on no-match OR ambiguity, same contract as
1624
+ * `resolveLabelFromHistory` — the caller falls back to the live
1625
+ * resolver's original error.
1626
+ */
1627
+ private resolveDmFromHistory(trimmed: string, mention: RegExpExecArray | null): string | undefined {
1628
+ // dmClassified/dmRecipientIds/dmRecipientNames are always populated
1629
+ // alongside allLabelsSeen (same appendLabelSighting call, same
1630
+ // falsy-label early return) — so allLabelsSeen's keys are a superset
1631
+ // of every channel any DM signal could exist for.
1632
+ const dmChannelIds = [...this.allLabelsSeen.keys()].filter((id) => this.isDmChannelId(id));
1633
+
1634
+ if (mention) {
1635
+ const id = mention[1]!;
1636
+ const matches = dmChannelIds.filter((cid) => this.dmRecipientIds.get(cid) === id);
1637
+ return matches.length === 1 ? matches[0] : undefined;
1638
+ }
1639
+
1640
+ const name = trimmed.slice(1).toLowerCase();
1641
+ const labelName = (channelId: string): string => {
1642
+ const label = (this.labelHistory.get(channelId) ?? '').toLowerCase();
1643
+ return label.startsWith('dm: ') ? label.slice(4) : label;
1644
+ };
1645
+
1646
+ // Three strict tiers, mirroring the LIVE resolver's precedence (which
1647
+ // prefers recipientName over the label outright, never a flat pool of
1648
+ // equal-priority candidates): (1) EXACT match against a channel's own
1649
+ // recorded recipientName, (2) EXACT match against a channel's label
1650
+ // (only tried when tier 1 found NOTHING — not merely "didn't match
1651
+ // this channel", genuinely zero matches across every dm channel), (3)
1652
+ // fuzzy/substring match across the combined name pool, as a last
1653
+ // resort. Each tier stops at real ambiguity (2+ matches) rather than
1654
+ // falling through — an ambiguous EXACT recipientName match is a
1655
+ // genuine collision between two real usernames, not something a
1656
+ // weaker label-based tier should silently resolve.
1657
+ //
1658
+ // Tier 1 taking outright precedence (not "additive" the way label
1659
+ // fuzzy-matching is within a single channel) is the actual fix: a flat
1660
+ // combined pool let a DIFFERENT channel's stale/unrelated display
1661
+ // label collide with THIS channel's real recorded username, making an
1662
+ // otherwise-unique lookup falsely ambiguous after a restart — even
1663
+ // though the persisted recipientName data was sufficient on its own to
1664
+ // resolve it. See the regression for the exact collision shape.
1665
+ const exactRecipientName = dmChannelIds.filter((cid) => this.dmRecipientNames.get(cid)?.toLowerCase() === name);
1666
+ if (exactRecipientName.length === 1) return exactRecipientName[0];
1667
+ if (exactRecipientName.length > 1) return undefined;
1668
+
1669
+ const exactLabel = dmChannelIds.filter((cid) => labelName(cid) === name);
1670
+ if (exactLabel.length === 1) return exactLabel[0];
1671
+ if (exactLabel.length > 1) return undefined;
1672
+
1673
+ const candidateNames = (channelId: string): string[] => {
1674
+ const names: string[] = [];
1675
+ const recipientName = this.dmRecipientNames.get(channelId);
1676
+ if (recipientName) names.push(recipientName.toLowerCase());
1677
+ const label = labelName(channelId);
1678
+ if (label) names.push(label);
1679
+ return names;
1680
+ };
1681
+ const fuzzy = dmChannelIds.filter((cid) =>
1682
+ candidateNames(cid).some((n) => n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name))),
1683
+ );
1684
+ return fuzzy.length === 1 ? fuzzy[0] : undefined;
1685
+ }
1686
+
1687
+ /**
1688
+ * Durable-history-aware counterpart to `resolveProseTarget()`. Resolves a
1689
+ * label or channelId to a canonical channel id even for a channel the bot
1690
+ * is not currently connected to (browsing message history is exactly the
1691
+ * case where you want to look at an old/quiet/disconnected channel, so the
1692
+ * live-only `resolveProseTarget()` isn't enough).
1693
+ *
1694
+ * Tries the live path first — unchanged, so ordinary addressing keeps its
1695
+ * exact current behavior — and only consults `labelHistory` on a live
1696
+ * miss. If history has no answer either, the original error/candidates
1697
+ * from `resolveProseTarget()` are returned unchanged, so callers keep the
1698
+ * same debuggability they'd get from the live-only path.
1699
+ *
1700
+ * CAVEAT (prose-misdelivery safety): if two channels once shared a label
1701
+ * but only ONE of them ever got a durable label-history record (e.g. the
1702
+ * other was lazily registered label-less, or predates this feature), this
1703
+ * can return a single confident answer where `resolveProseTarget()` on
1704
+ * fuller live data would have said "ambiguous" — the durable fallback
1705
+ * only sees what was actually persisted, not what's structurally true.
1706
+ * This is harmless for the current sole caller
1707
+ * (`HistoryModule.resolveChannel`, read-only), but a future caller wiring
1708
+ * this into a SEND path should be aware the ambiguity guarantee is weaker
1709
+ * here than on the live path.
1710
+ */
1711
+ resolveProseTargetDurable(
1712
+ spec: string,
1713
+ ): { channelId: string; label?: string } | { error: string; candidates?: string[] } {
1714
+ const live = this.resolveProseTarget(spec);
1715
+ if (!('error' in live)) return live;
1716
+
1717
+ const channelId = this.resolveLabelFromHistory(spec);
1718
+ if (channelId !== undefined) {
1719
+ return { channelId, label: this.labelHistory.get(channelId) };
1720
+ }
1721
+ return live;
1722
+ }
1723
+
1231
1724
  private setDesiredState(
1232
1725
  serverId: string,
1233
1726
  channelId: string,
@@ -1755,7 +2248,7 @@ export class ChannelRegistry {
1755
2248
  };
1756
2249
  }
1757
2250
 
1758
- const norm = (s: string) => s.replace(/^#/, '').toLowerCase();
2251
+ const norm = normalizeChannelLabel;
1759
2252
 
1760
2253
  // DM addressing is PEOPLE-first: usernames and mention tokens, never
1761
2254
  // ids-only (2026-07-24, antra + Fable's live-canary bug report). A DM
@@ -1831,7 +2324,7 @@ export class ChannelRegistry {
1831
2324
  // must resolve. Strip a trailing parenthetical from the stored label and
1832
2325
  // compare the bare channel name. Same name in several guilds is a real
1833
2326
  // ambiguity: error with full labels so the agent can use the exact id.
1834
- const nameOf = (label: string) => norm(label.replace(/\s*\([^)]*\)\s*$/, ''));
2327
+ const nameOf = normalizeChannelLabelName;
1835
2328
  const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed));
1836
2329
  if (byName.length === 1) {
1837
2330
  return { channelId: byName[0]!.descriptor.id, label: byName[0]!.descriptor.label };