@animalabs/agent-framework 0.14.0 → 0.16.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 (134) hide show
  1. package/README.md +22 -1
  2. package/dist/src/agent.d.ts +42 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +94 -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 -5
  13. package/dist/src/framework.d.ts.map +1 -1
  14. package/dist/src/framework.js +1906 -102
  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 +145 -0
  33. package/dist/src/modules/history/index.d.ts.map +1 -0
  34. package/dist/src/modules/history/index.js +1150 -0
  35. package/dist/src/modules/history/index.js.map +1 -0
  36. package/dist/src/modules/history/search-regex-worker.d.ts +23 -0
  37. package/dist/src/modules/history/search-regex-worker.d.ts.map +1 -0
  38. package/dist/src/modules/history/search-regex-worker.js +57 -0
  39. package/dist/src/modules/history/search-regex-worker.js.map +1 -0
  40. package/dist/src/modules/index.d.ts +1 -0
  41. package/dist/src/modules/index.d.ts.map +1 -1
  42. package/dist/src/modules/index.js +1 -0
  43. package/dist/src/modules/index.js.map +1 -1
  44. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  45. package/dist/src/modules/workspace/index.js +42 -1
  46. package/dist/src/modules/workspace/index.js.map +1 -1
  47. package/dist/src/modules/workspace/types.d.ts +5 -0
  48. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  49. package/dist/src/modules/workspace/types.js.map +1 -1
  50. package/dist/src/operator-log.d.ts +78 -0
  51. package/dist/src/operator-log.d.ts.map +1 -0
  52. package/dist/src/operator-log.js +102 -0
  53. package/dist/src/operator-log.js.map +1 -0
  54. package/dist/src/recovery/discord-awareness-outbox.d.ts +10 -0
  55. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  56. package/dist/src/recovery/discord-awareness-outbox.js +20 -0
  57. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  58. package/dist/src/types/framework.d.ts +24 -0
  59. package/dist/src/types/framework.d.ts.map +1 -1
  60. package/dist/src/types/trace.d.ts +40 -0
  61. package/dist/src/types/trace.d.ts.map +1 -1
  62. package/dist/test/agent-runtime-settings.test.js +199 -1
  63. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  64. package/dist/test/api-admin-gate.test.d.ts +2 -0
  65. package/dist/test/api-admin-gate.test.d.ts.map +1 -0
  66. package/dist/test/api-admin-gate.test.js +116 -0
  67. package/dist/test/api-admin-gate.test.js.map +1 -0
  68. package/dist/test/channel-label-history.test.d.ts +2 -0
  69. package/dist/test/channel-label-history.test.d.ts.map +1 -0
  70. package/dist/test/channel-label-history.test.js +424 -0
  71. package/dist/test/channel-label-history.test.js.map +1 -0
  72. package/dist/test/discord-awareness-outbox.test.js +16 -0
  73. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  74. package/dist/test/event-gate.test.js +44 -0
  75. package/dist/test/event-gate.test.js.map +1 -1
  76. package/dist/test/helpers/quiesce-crash-child.d.ts +2 -0
  77. package/dist/test/helpers/quiesce-crash-child.d.ts.map +1 -0
  78. package/dist/test/helpers/quiesce-crash-child.js +156 -0
  79. package/dist/test/helpers/quiesce-crash-child.js.map +1 -0
  80. package/dist/test/history-module-overview.test.d.ts +2 -0
  81. package/dist/test/history-module-overview.test.d.ts.map +1 -0
  82. package/dist/test/history-module-overview.test.js +711 -0
  83. package/dist/test/history-module-overview.test.js.map +1 -0
  84. package/dist/test/history-module.test.d.ts +2 -0
  85. package/dist/test/history-module.test.d.ts.map +1 -0
  86. package/dist/test/history-module.test.js +595 -0
  87. package/dist/test/history-module.test.js.map +1 -0
  88. package/dist/test/host-quiesce.test.d.ts +2 -0
  89. package/dist/test/host-quiesce.test.d.ts.map +1 -0
  90. package/dist/test/host-quiesce.test.js +863 -0
  91. package/dist/test/host-quiesce.test.js.map +1 -0
  92. package/dist/test/kv-unified-open-flight-retry.test.d.ts +2 -0
  93. package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +1 -0
  94. package/dist/test/kv-unified-open-flight-retry.test.js +189 -0
  95. package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -0
  96. package/dist/test/kv-unified-wiring.test.js +39 -0
  97. package/dist/test/kv-unified-wiring.test.js.map +1 -1
  98. package/dist/test/operator-surgery.test.d.ts +2 -0
  99. package/dist/test/operator-surgery.test.d.ts.map +1 -0
  100. package/dist/test/operator-surgery.test.js +398 -0
  101. package/dist/test/operator-surgery.test.js.map +1 -0
  102. package/dist/test/puppet-tool-call.test.js +118 -1
  103. package/dist/test/puppet-tool-call.test.js.map +1 -1
  104. package/dist/test/puppet-turn-reservation.test.d.ts +2 -0
  105. package/dist/test/puppet-turn-reservation.test.d.ts.map +1 -0
  106. package/dist/test/puppet-turn-reservation.test.js +139 -0
  107. package/dist/test/puppet-turn-reservation.test.js.map +1 -0
  108. package/dist/test/save-recent-image.test.js +29 -0
  109. package/dist/test/save-recent-image.test.js.map +1 -1
  110. package/dist/test/tool-result-spill.test.js +114 -0
  111. package/dist/test/tool-result-spill.test.js.map +1 -1
  112. package/dist/test/workspace-read-text.test.d.ts +2 -0
  113. package/dist/test/workspace-read-text.test.d.ts.map +1 -0
  114. package/dist/test/workspace-read-text.test.js +101 -0
  115. package/dist/test/workspace-read-text.test.js.map +1 -0
  116. package/dist/tsconfig.tsbuildinfo +1 -1
  117. package/package.json +3 -3
  118. package/src/agent.ts +110 -0
  119. package/src/api/server.ts +159 -2
  120. package/src/api/types.ts +15 -0
  121. package/src/framework.ts +2196 -115
  122. package/src/gate/event-gate.ts +47 -4
  123. package/src/index.ts +5 -1
  124. package/src/mcpl/channel-registry.ts +495 -2
  125. package/src/mcpl/server-connection.ts +73 -13
  126. package/src/modules/history/index.ts +1277 -0
  127. package/src/modules/history/search-regex-worker.ts +76 -0
  128. package/src/modules/index.ts +2 -0
  129. package/src/modules/workspace/index.ts +42 -1
  130. package/src/modules/workspace/types.ts +5 -0
  131. package/src/operator-log.ts +132 -0
  132. package/src/recovery/discord-awareness-outbox.ts +19 -0
  133. package/src/types/framework.ts +27 -0
  134. package/src/types/trace.ts +46 -0
@@ -20,6 +20,49 @@ import { CapabilityGrant } from './capability-grant.js';
20
20
  // ============================================================================
21
21
  const TYPING_INTERVAL_MS = 7_000;
22
22
  const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
23
+ /**
24
+ * Durable "which label did this channelId have, each time we saw it" log.
25
+ * `resolveProseTarget()` resolves a label to an id only for channels
26
+ * currently in the live `channels` map — fine for normal addressing, but
27
+ * useless for browsing history on a channel the bot has since disconnected
28
+ * from (or that didn't survive a restart). This log lets that lookup survive
29
+ * a disconnect/restart by replaying the most recent label sighting per
30
+ * channelId. See `labelHistory`, `appendLabelSighting`, and
31
+ * `resolveProseTargetDurable`.
32
+ */
33
+ const CHANNEL_LABEL_HISTORY_LOG_ID = 'mcpl/channel-label-history';
34
+ /** Extract a DM's identity/classification fields from a descriptor's
35
+ * metadata, if present — mirrors `resolveProseTarget()`'s own local
36
+ * `dmMeta()` read (`recipientId`, `recipientName`, `channelType`). Used
37
+ * to durably persist them alongside a label sighting (see
38
+ * `ChannelLabelSightingEvent`'s corresponding fields), captured at the
39
+ * SAME point (sighting time) rather than re-derived later from id/label
40
+ * shape, which is exactly what the live resolver does NOT do either. */
41
+ function extractDmMeta(metadata) {
42
+ const recipientId = typeof metadata?.recipientId === 'string' ? metadata.recipientId : undefined;
43
+ const recipientName = typeof metadata?.recipientName === 'string' ? metadata.recipientName : undefined;
44
+ const isDm = metadata?.channelType === 'dm' ? true : undefined;
45
+ return { recipientId, recipientName, isDm };
46
+ }
47
+ /**
48
+ * Case-insensitive channel-label comparison key: strips a leading '#' and
49
+ * lowercases. Mirrors the normalization `resolveProseTarget()` applies to
50
+ * its live-channel label matches (see the local `norm` there) — factored out
51
+ * so `resolveLabelFromHistory()` recognizes the same spellings without
52
+ * duplicating (and risking drift from) the live matching rules.
53
+ */
54
+ function normalizeChannelLabel(s) {
55
+ return s.replace(/^#/, '').toLowerCase();
56
+ }
57
+ /**
58
+ * Same as `normalizeChannelLabel`, but also strips a trailing parenthetical
59
+ * guild/server suffix (e.g. "#fable (antra's server)" -> "fable") so a bare
60
+ * channel name matches the disambiguated label agents often omit. Mirrors
61
+ * `resolveProseTarget()`'s local `nameOf` helper.
62
+ */
63
+ function normalizeChannelLabelName(label) {
64
+ return normalizeChannelLabel(label.replace(/\s*\([^)]*\)\s*$/, ''));
65
+ }
23
66
  function shallowEqualRecord(a, b) {
24
67
  if (a === b)
25
68
  return true;
@@ -233,6 +276,51 @@ export class ChannelRegistry {
233
276
  store;
234
277
  /** Registered channels, keyed by `{serverId}:{channelId}`. */
235
278
  channels = new Map();
279
+ /**
280
+ * Most-recently-known label per channelId, replayed from
281
+ * `CHANNEL_LABEL_HISTORY_LOG_ID` (last sighting wins) and kept current by
282
+ * `appendLabelSighting()`. Unlike `channels`, this survives disconnect and
283
+ * restart — it's what lets `resolveProseTargetDurable()` resolve a label
284
+ * for a channel that's no longer live. Keyed by bare channelId, not
285
+ * `{serverId}:{channelId}` — the label history exists to answer "what id
286
+ * did this label refer to", independent of which server it came through.
287
+ */
288
+ labelHistory = new Map();
289
+ /**
290
+ * EVERY distinct label a channelId has ever been durably seen with (not
291
+ * just the latest) — additive alongside `labelHistory`, populated by the
292
+ * same replay-at-boot loop and the same `appendLabelSighting()` call.
293
+ * `labelHistory` alone can only resolve a channel's CURRENT/latest name;
294
+ * this is what lets `resolveLabelFromHistory()` find a channel by a label
295
+ * it used to have before a rename — the exact case this whole durable log
296
+ * exists for (finding an old/renamed channel while browsing history).
297
+ */
298
+ allLabelsSeen = new Map();
299
+ /**
300
+ * DM recipientId per channelId, durably replayed alongside `labelHistory`
301
+ * — the piece a `<@id>` mention-form lookup needs that `allLabelsSeen`'s
302
+ * label strings alone can't provide. See `resolveLabelFromHistory()`'s
303
+ * DM-aware arm.
304
+ */
305
+ dmRecipientIds = new Map();
306
+ /**
307
+ * DM recipient's actual username per channelId — can differ from the
308
+ * channel's display label (`resolveProseTarget()`'s live DM matching
309
+ * prefers this over the label for exactly that reason). See
310
+ * `resolveDmFromHistory()`.
311
+ */
312
+ dmRecipientNames = new Map();
313
+ /**
314
+ * channelId -> true, for every channel EXPLICITLY classified as a DM via
315
+ * `metadata.channelType === 'dm'` at some sighting — an id/label-shape-
316
+ * independent classification signal, the same one `resolveProseTarget()`
317
+ * checks live. Only ever holds `true`; a channel with no explicit
318
+ * classification is simply absent (not `false`), so `isDmChannelId()`
319
+ * falls back to id/label-shape heuristics for it rather than durably
320
+ * asserting a negative for the common case where this metadata was never
321
+ * sent at all.
322
+ */
323
+ dmClassified = new Map();
236
324
  /** Most recent incoming channel ID — used for speech routing / default publish. */
237
325
  defaultPublishChannel = null;
238
326
  /** Most recent incoming message metadata, used for buildChannelContext. */
@@ -263,6 +351,7 @@ export class ChannelRegistry {
263
351
  this.activeChannelResolver = options?.activeChannelResolver;
264
352
  this.store = options?.store;
265
353
  this.initializeLifecycleStore();
354
+ this.initializeLabelHistoryStore();
266
355
  }
267
356
  /**
268
357
  * Supply a legacy recipe policy for one-time migration into Chronicle.
@@ -300,6 +389,7 @@ export class ChannelRegistry {
300
389
  descriptor: channel,
301
390
  open: false,
302
391
  });
392
+ this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
303
393
  registeredIds.push(channel.id);
304
394
  results.push({ id: channel.id, accepted: true });
305
395
  }
@@ -360,6 +450,7 @@ export class ChannelRegistry {
360
450
  continue;
361
451
  }
362
452
  existing.descriptor = channel;
453
+ this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
363
454
  addedResults.push({ id: channel.id, accepted: true });
364
455
  }
365
456
  }
@@ -378,6 +469,7 @@ export class ChannelRegistry {
378
469
  descriptor: channel,
379
470
  open: false,
380
471
  });
472
+ this.appendLabelSighting(channel.id, channel.label, extractDmMeta(channel.metadata));
381
473
  addedResults.push({ id: channel.id, accepted: true });
382
474
  accepted.push(channel);
383
475
  }
@@ -539,6 +631,8 @@ export class ChannelRegistry {
539
631
  if (extraMetadata) {
540
632
  existing.descriptor.metadata = { ...existing.descriptor.metadata, ...extraMetadata };
541
633
  }
634
+ // The channel now has a real label, not just the bare id — durable.
635
+ this.appendLabelSighting(existing.descriptor.id, existing.descriptor.label, extractDmMeta(existing.descriptor.metadata));
542
636
  }
543
637
  return;
544
638
  }
@@ -554,6 +648,20 @@ export class ChannelRegistry {
554
648
  },
555
649
  open: false,
556
650
  });
651
+ // Only persist a REAL label durably. Falling back to the bare
652
+ // channelId as a placeholder (no server-supplied label at all — e.g. a
653
+ // Discord DM push missing both channelName and an author name,
654
+ // framework.ts's derivePushEventChannel) must never overwrite a real
655
+ // label already on file from a previous boot: after a restart the live
656
+ // `channels` map starts empty, so THIS is the very branch a
657
+ // label-less post-restart event takes — recording the placeholder here
658
+ // would clobber the good label initializeLabelHistoryStore() just
659
+ // replayed, defeating the entire point of the durable log. The live
660
+ // descriptor above still carries the placeholder for in-process
661
+ // routing; only the durable write is skipped when there's no real
662
+ // label to record.
663
+ if (label)
664
+ this.appendLabelSighting(channelId, label, extractDmMeta(extraMetadata));
557
665
  this.emitTraceFn({
558
666
  type: 'mcpl:channel-lazy-registered',
559
667
  serverId,
@@ -864,6 +972,340 @@ export class ChannelRegistry {
864
972
  appendLifecycleEvent(event) {
865
973
  this.store?.appendToStateJson(CHANNEL_LIFECYCLE_LOG_ID, event);
866
974
  }
975
+ // ==========================================================================
976
+ // Private: Durable channel-label history (disconnected-channel resolution)
977
+ // ==========================================================================
978
+ /**
979
+ * Replay `CHANNEL_LABEL_HISTORY_LOG_ID` into `labelHistory`. Same
980
+ * construction-time timing as `initializeLifecycleStore()`, and the same
981
+ * register-if-missing / swallow-"already exists" pattern. The log is
982
+ * chronological, so folding forward and letting each record overwrite the
983
+ * map entry for its channelId naturally keeps only the latest sighting.
984
+ */
985
+ initializeLabelHistoryStore() {
986
+ if (!this.store)
987
+ return;
988
+ try {
989
+ this.store.registerState({ id: CHANNEL_LABEL_HISTORY_LOG_ID, strategy: 'append_log' });
990
+ }
991
+ catch (error) {
992
+ if (!(error instanceof Error) || !error.message.includes('State already exists')) {
993
+ throw error;
994
+ }
995
+ }
996
+ const raw = this.store.getStateJson(CHANNEL_LABEL_HISTORY_LOG_ID);
997
+ if (!Array.isArray(raw))
998
+ return;
999
+ for (const item of raw) {
1000
+ if (!item || typeof item !== 'object')
1001
+ continue;
1002
+ const event = item;
1003
+ if (typeof event.channelId !== 'string' || typeof event.label !== 'string' || !event.label)
1004
+ continue;
1005
+ this.labelHistory.set(event.channelId, event.label);
1006
+ let seen = this.allLabelsSeen.get(event.channelId);
1007
+ if (!seen) {
1008
+ seen = new Set();
1009
+ this.allLabelsSeen.set(event.channelId, seen);
1010
+ }
1011
+ seen.add(event.label);
1012
+ if (typeof event.recipientId === 'string' && event.recipientId) {
1013
+ this.dmRecipientIds.set(event.channelId, event.recipientId);
1014
+ }
1015
+ if (typeof event.recipientName === 'string' && event.recipientName) {
1016
+ this.dmRecipientNames.set(event.channelId, event.recipientName);
1017
+ }
1018
+ if (event.isDm === true) {
1019
+ this.dmClassified.set(event.channelId, true);
1020
+ }
1021
+ }
1022
+ }
1023
+ /**
1024
+ * Record that `channelId` was most recently seen with `label` (and,
1025
+ * optionally, DM identity/classification fields). Called from every
1026
+ * place a `ChannelDescriptor` is set into the live `channels` map
1027
+ * (`handleRegister`, `handleChanged`'s update/add branches,
1028
+ * `ensureChannelRegistered`) so the label survives disconnect and restart
1029
+ * for `resolveLabelFromHistory()` / `resolveProseTargetDurable()`.
1030
+ *
1031
+ * Guarded like `setDesiredState()`: a reconnect or re-registration that
1032
+ * reports the same label AND the same dmMeta fields as last time is a
1033
+ * no-op, so the append log doesn't grow without bound on every
1034
+ * boot/resubscribe — but a later call that newly supplies (or changes)
1035
+ * any dmMeta field for an already-known label (label unchanged) still
1036
+ * records, since that's genuinely new durable information, not a no-op
1037
+ * resighting. `isDm` only ever compares against a possible narrowing:
1038
+ * once durably `true`, a later sighting without `channelType==='dm'`
1039
+ * metadata (i.e. `dmMeta.isDm === undefined`, NOT `false` — see
1040
+ * `extractDmMeta`) is simply silent on the question, not a "no longer a
1041
+ * DM" downgrade, so it never counts as a change on its own.
1042
+ *
1043
+ * `label` is `string | undefined` (not just `string`) even though
1044
+ * `ChannelDescriptor.label` is typed `string`: that typing is a
1045
+ * compile-time-only assertion over untrusted wire data from an MCPL
1046
+ * server, and only `channel.id` is runtime-validated on the way in
1047
+ * (`handleRegister`/`handleChanged`). A missing/undefined label is
1048
+ * dropped rather than recorded — durable garbage here would later throw
1049
+ * inside `resolveLabelFromHistory()`'s normalization, which every
1050
+ * `HistoryModule` tool call passes through unguarded via
1051
+ * `resolveProseTargetDurable()`.
1052
+ */
1053
+ appendLabelSighting(channelId, label, dmMeta = {}) {
1054
+ if (typeof label !== 'string' || !label)
1055
+ return;
1056
+ const { recipientId, recipientName, isDm } = dmMeta;
1057
+ // Never let a bare-id placeholder (ensureChannelRegistered's fallback
1058
+ // `label ?? channelId` when no real label was ever supplied — e.g. a
1059
+ // Discord DM push missing both channelName and an author name) durably
1060
+ // overwrite a real label already on file. This is exactly the restart
1061
+ // scenario the durable log exists to survive: the live `channels` map
1062
+ // is empty after a restart, so the next label-less event would
1063
+ // otherwise take the "first sighting" path and clobber a good label
1064
+ // the replay above just restored. (Belt-and-braces alongside
1065
+ // ensureChannelRegistered's own guard, which should stop this before
1066
+ // it ever gets here — see there for the primary fix.)
1067
+ if (label === channelId && this.labelHistory.has(channelId))
1068
+ return;
1069
+ const labelUnchanged = this.labelHistory.get(channelId) === label;
1070
+ const recipientIdUnchanged = recipientId === undefined || recipientId === this.dmRecipientIds.get(channelId);
1071
+ const recipientNameUnchanged = recipientName === undefined || recipientName === this.dmRecipientNames.get(channelId);
1072
+ const isDmUnchanged = isDm === undefined || this.dmClassified.get(channelId) === true;
1073
+ if (labelUnchanged && recipientIdUnchanged && recipientNameUnchanged && isDmUnchanged)
1074
+ return;
1075
+ this.labelHistory.set(channelId, label);
1076
+ let seen = this.allLabelsSeen.get(channelId);
1077
+ if (!seen) {
1078
+ seen = new Set();
1079
+ this.allLabelsSeen.set(channelId, seen);
1080
+ }
1081
+ seen.add(label);
1082
+ if (recipientId)
1083
+ this.dmRecipientIds.set(channelId, recipientId);
1084
+ if (recipientName)
1085
+ this.dmRecipientNames.set(channelId, recipientName);
1086
+ if (isDm)
1087
+ this.dmClassified.set(channelId, true);
1088
+ this.store?.appendToStateJson(CHANNEL_LABEL_HISTORY_LOG_ID, {
1089
+ channelId,
1090
+ label,
1091
+ ts: Date.now(),
1092
+ ...(recipientId ? { recipientId } : {}),
1093
+ ...(recipientName ? { recipientName } : {}),
1094
+ ...(isDm ? { isDm } : {}),
1095
+ });
1096
+ }
1097
+ /**
1098
+ * Look up a label spec against label *history* rather than the live
1099
+ * `channels` map — the fallback path for a channel the bot isn't
1100
+ * currently connected to. Applies the same normalization
1101
+ * `resolveProseTarget()` uses for its live label/name matches
1102
+ * (`normalizeChannelLabel` / `normalizeChannelLabelName`), plus a raw-id
1103
+ * fast path (mirroring `resolveProseTarget()`'s own raw-id acceptance).
1104
+ *
1105
+ * Matches against `allLabelsSeen` — EVERY label a channel has ever had —
1106
+ * not just its current/latest one in `labelHistory`, so a channel renamed
1107
+ * A -> B -> A is still resolvable by a name from BEFORE the most recent
1108
+ * rename (the point of a history-browsing tool is finding things by what
1109
+ * they used to be called). Ambiguity is genuine here: if the same
1110
+ * normalized spec matches historical labels belonging to two DIFFERENT
1111
+ * channelIds (an actual rename collision, not just the same channel seen
1112
+ * twice), that's a real ambiguity, not a false positive from re-scanning
1113
+ * one channel's own label list — `byLabel`/`byName` tracking below
1114
+ * already only flags ambiguous when the matched channelId itself differs.
1115
+ *
1116
+ * Returns undefined — rather than raising an ambiguity error — when a
1117
+ * normalized spec matches more than one distinct channelId in history;
1118
+ * callers fall back to `resolveProseTarget()`'s original error in that
1119
+ * case via `resolveProseTargetDurable()`.
1120
+ */
1121
+ resolveLabelFromHistory(spec) {
1122
+ const trimmed = spec.trim();
1123
+ if (!trimmed)
1124
+ return undefined;
1125
+ // Fast path: spec is already a channelId we have label history for.
1126
+ if (this.labelHistory.has(trimmed))
1127
+ return trimmed;
1128
+ // DM-aware arm, mirroring resolveProseTarget()'s own `@name` / `<@id>`
1129
+ // handling (see there) — but over durable history instead of the live
1130
+ // `channels` map, so a disconnected/post-restart DM stays addressable
1131
+ // by the natural forms an agent would type, not just the exact stored
1132
+ // label string. Tried BEFORE the generic byLabel/byName loop below
1133
+ // because a bare `@antra` would otherwise also partially match via
1134
+ // normalizeChannelLabel's '#'-only stripping (which does nothing for a
1135
+ // leading '@') and fail confusingly instead of going through DM rules.
1136
+ const mention = /^<@!?(\d+)>$/.exec(trimmed);
1137
+ if (trimmed.startsWith('@') || mention) {
1138
+ return this.resolveDmFromHistory(trimmed, mention);
1139
+ }
1140
+ const normSpec = normalizeChannelLabel(trimmed);
1141
+ let byLabel;
1142
+ let byLabelAmbiguous = false;
1143
+ let byName;
1144
+ let byNameAmbiguous = false;
1145
+ for (const [channelId, labels] of this.allLabelsSeen) {
1146
+ for (const label of labels) {
1147
+ // `?? ''` is belt-and-braces: appendLabelSighting/replay already
1148
+ // refuse to store a falsy label, so this should never see one, but
1149
+ // normalizeChannelLabel has no internal guard of its own (unlike
1150
+ // resolveProseTarget's `e.descriptor.label ?? ''`), and this is
1151
+ // exactly the kind of one-bad-record-poisons-every-future-call path
1152
+ // that caused finding #2.
1153
+ const normLabel = normalizeChannelLabel(label ?? '');
1154
+ const normName = normalizeChannelLabelName(label ?? '');
1155
+ if (normLabel === normSpec) {
1156
+ if (byLabel !== undefined && byLabel !== channelId)
1157
+ byLabelAmbiguous = true;
1158
+ byLabel = channelId;
1159
+ }
1160
+ if (normName === normSpec) {
1161
+ if (byName !== undefined && byName !== channelId)
1162
+ byNameAmbiguous = true;
1163
+ byName = channelId;
1164
+ }
1165
+ }
1166
+ }
1167
+ if (byLabel !== undefined && !byLabelAmbiguous)
1168
+ return byLabel;
1169
+ if (byName !== undefined && !byNameAmbiguous)
1170
+ return byName;
1171
+ return undefined;
1172
+ }
1173
+ /**
1174
+ * A channel counts as a DM (for the purposes of durable-history lookup)
1175
+ * if: it was EXPLICITLY classified as one via `metadata.channelType ===
1176
+ * 'dm'` at some sighting (`dmClassified` — the same classification
1177
+ * signal `resolveProseTarget()`'s live `isDmEntry()` checks FIRST, before
1178
+ * any naming convention, so an id/label that don't follow the usual
1179
+ * `:dm:`/`DM: ` shape still get recognized correctly — this is what
1180
+ * `private-room-42` labeled just `Tess` needs); OR, as a fallback for
1181
+ * descriptors that never carried that metadata at all, its id shape says
1182
+ * so, or any label it has ever carried says so.
1183
+ */
1184
+ isDmChannelId(channelId) {
1185
+ if (this.dmClassified.get(channelId) === true)
1186
+ return true;
1187
+ if (channelId.includes(':dm:'))
1188
+ return true;
1189
+ const labels = this.allLabelsSeen.get(channelId);
1190
+ if (!labels)
1191
+ return false;
1192
+ for (const label of labels) {
1193
+ if (label.toLowerCase().startsWith('dm: '))
1194
+ return true;
1195
+ }
1196
+ return false;
1197
+ }
1198
+ /**
1199
+ * Durable-history counterpart to `resolveProseTarget()`'s DM-matching arm
1200
+ * (see there for the live version this mirrors). Handles the two forms
1201
+ * `resolveProseTarget()` supports: a `<@id>` mention (matched against
1202
+ * `dmRecipientIds`, persisted alongside a label sighting specifically for
1203
+ * this) and a bare `@name` (matched against EITHER the persisted
1204
+ * `dmRecipientNames` — the DM's actual username, which the live resolver
1205
+ * prefers and which can differ from the display label, e.g. label
1206
+ * "DM: Tess" but username "antra_tessera" — OR the `DM: `-stripped form
1207
+ * of each DM channel's CURRENT label in `labelHistory`, as a fallback for
1208
+ * descriptors that never carried a recipientName; historical DM labels
1209
+ * aren't name-matched here the way non-DM labels are in
1210
+ * `resolveLabelFromHistory`'s main loop, since a DM's label is a person's
1211
+ * name, not a channel name subject to the same kind of rename).
1212
+ *
1213
+ * Returns undefined on no-match OR ambiguity, same contract as
1214
+ * `resolveLabelFromHistory` — the caller falls back to the live
1215
+ * resolver's original error.
1216
+ */
1217
+ resolveDmFromHistory(trimmed, mention) {
1218
+ // dmClassified/dmRecipientIds/dmRecipientNames are always populated
1219
+ // alongside allLabelsSeen (same appendLabelSighting call, same
1220
+ // falsy-label early return) — so allLabelsSeen's keys are a superset
1221
+ // of every channel any DM signal could exist for.
1222
+ const dmChannelIds = [...this.allLabelsSeen.keys()].filter((id) => this.isDmChannelId(id));
1223
+ if (mention) {
1224
+ const id = mention[1];
1225
+ const matches = dmChannelIds.filter((cid) => this.dmRecipientIds.get(cid) === id);
1226
+ return matches.length === 1 ? matches[0] : undefined;
1227
+ }
1228
+ const name = trimmed.slice(1).toLowerCase();
1229
+ const labelName = (channelId) => {
1230
+ const label = (this.labelHistory.get(channelId) ?? '').toLowerCase();
1231
+ return label.startsWith('dm: ') ? label.slice(4) : label;
1232
+ };
1233
+ // Three strict tiers, mirroring the LIVE resolver's precedence (which
1234
+ // prefers recipientName over the label outright, never a flat pool of
1235
+ // equal-priority candidates): (1) EXACT match against a channel's own
1236
+ // recorded recipientName, (2) EXACT match against a channel's label
1237
+ // (only tried when tier 1 found NOTHING — not merely "didn't match
1238
+ // this channel", genuinely zero matches across every dm channel), (3)
1239
+ // fuzzy/substring match across the combined name pool, as a last
1240
+ // resort. Each tier stops at real ambiguity (2+ matches) rather than
1241
+ // falling through — an ambiguous EXACT recipientName match is a
1242
+ // genuine collision between two real usernames, not something a
1243
+ // weaker label-based tier should silently resolve.
1244
+ //
1245
+ // Tier 1 taking outright precedence (not "additive" the way label
1246
+ // fuzzy-matching is within a single channel) is the actual fix: a flat
1247
+ // combined pool let a DIFFERENT channel's stale/unrelated display
1248
+ // label collide with THIS channel's real recorded username, making an
1249
+ // otherwise-unique lookup falsely ambiguous after a restart — even
1250
+ // though the persisted recipientName data was sufficient on its own to
1251
+ // resolve it. See the regression for the exact collision shape.
1252
+ const exactRecipientName = dmChannelIds.filter((cid) => this.dmRecipientNames.get(cid)?.toLowerCase() === name);
1253
+ if (exactRecipientName.length === 1)
1254
+ return exactRecipientName[0];
1255
+ if (exactRecipientName.length > 1)
1256
+ return undefined;
1257
+ const exactLabel = dmChannelIds.filter((cid) => labelName(cid) === name);
1258
+ if (exactLabel.length === 1)
1259
+ return exactLabel[0];
1260
+ if (exactLabel.length > 1)
1261
+ return undefined;
1262
+ const candidateNames = (channelId) => {
1263
+ const names = [];
1264
+ const recipientName = this.dmRecipientNames.get(channelId);
1265
+ if (recipientName)
1266
+ names.push(recipientName.toLowerCase());
1267
+ const label = labelName(channelId);
1268
+ if (label)
1269
+ names.push(label);
1270
+ return names;
1271
+ };
1272
+ const fuzzy = dmChannelIds.filter((cid) => candidateNames(cid).some((n) => n.length >= 3 && (n.startsWith(name) || name.startsWith(n) || n.includes(name))));
1273
+ return fuzzy.length === 1 ? fuzzy[0] : undefined;
1274
+ }
1275
+ /**
1276
+ * Durable-history-aware counterpart to `resolveProseTarget()`. Resolves a
1277
+ * label or channelId to a canonical channel id even for a channel the bot
1278
+ * is not currently connected to (browsing message history is exactly the
1279
+ * case where you want to look at an old/quiet/disconnected channel, so the
1280
+ * live-only `resolveProseTarget()` isn't enough).
1281
+ *
1282
+ * Tries the live path first — unchanged, so ordinary addressing keeps its
1283
+ * exact current behavior — and only consults `labelHistory` on a live
1284
+ * miss. If history has no answer either, the original error/candidates
1285
+ * from `resolveProseTarget()` are returned unchanged, so callers keep the
1286
+ * same debuggability they'd get from the live-only path.
1287
+ *
1288
+ * CAVEAT (prose-misdelivery safety): if two channels once shared a label
1289
+ * but only ONE of them ever got a durable label-history record (e.g. the
1290
+ * other was lazily registered label-less, or predates this feature), this
1291
+ * can return a single confident answer where `resolveProseTarget()` on
1292
+ * fuller live data would have said "ambiguous" — the durable fallback
1293
+ * only sees what was actually persisted, not what's structurally true.
1294
+ * This is harmless for the current sole caller
1295
+ * (`HistoryModule.resolveChannel`, read-only), but a future caller wiring
1296
+ * this into a SEND path should be aware the ambiguity guarantee is weaker
1297
+ * here than on the live path.
1298
+ */
1299
+ resolveProseTargetDurable(spec) {
1300
+ const live = this.resolveProseTarget(spec);
1301
+ if (!('error' in live))
1302
+ return live;
1303
+ const channelId = this.resolveLabelFromHistory(spec);
1304
+ if (channelId !== undefined) {
1305
+ return { channelId, label: this.labelHistory.get(channelId) };
1306
+ }
1307
+ return live;
1308
+ }
867
1309
  setDesiredState(serverId, channelId, desired, source) {
868
1310
  const key = this.lifecycleKey(serverId, channelId);
869
1311
  if (this.desiredStates.get(key)?.state === desired)
@@ -1316,7 +1758,7 @@ export class ChannelRegistry {
1316
1758
  candidates: raw.map((e) => e.descriptor.id),
1317
1759
  };
1318
1760
  }
1319
- const norm = (s) => s.replace(/^#/, '').toLowerCase();
1761
+ const norm = normalizeChannelLabel;
1320
1762
  // DM addressing is PEOPLE-first: usernames and mention tokens, never
1321
1763
  // ids-only (2026-07-24, antra + Fable's live-canary bug report). A DM
1322
1764
  // entry is matched by, in order: recipientId (from a `<@id>` mention
@@ -1384,7 +1826,7 @@ export class ChannelRegistry {
1384
1826
  // must resolve. Strip a trailing parenthetical from the stored label and
1385
1827
  // compare the bare channel name. Same name in several guilds is a real
1386
1828
  // ambiguity: error with full labels so the agent can use the exact id.
1387
- const nameOf = (label) => norm(label.replace(/\s*\([^)]*\)\s*$/, ''));
1829
+ const nameOf = normalizeChannelLabelName;
1388
1830
  const byName = entries.filter((e) => nameOf(e.descriptor.label ?? '') === norm(trimmed));
1389
1831
  if (byName.length === 1) {
1390
1832
  return { channelId: byName[0].descriptor.id, label: byName[0].descriptor.label };