@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.
- package/README.md +22 -1
- package/dist/src/agent.d.ts +42 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +94 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts +9 -0
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +156 -0
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/api/types.d.ts +15 -0
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/framework.d.ts +423 -5
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1906 -102
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +16 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +41 -3
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +172 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +444 -2
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +9 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +71 -13
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +145 -0
- package/dist/src/modules/history/index.d.ts.map +1 -0
- package/dist/src/modules/history/index.js +1150 -0
- package/dist/src/modules/history/index.js.map +1 -0
- package/dist/src/modules/history/search-regex-worker.d.ts +23 -0
- package/dist/src/modules/history/search-regex-worker.d.ts.map +1 -0
- package/dist/src/modules/history/search-regex-worker.js +57 -0
- package/dist/src/modules/history/search-regex-worker.js.map +1 -0
- package/dist/src/modules/index.d.ts +1 -0
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -0
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +42 -1
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +5 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/operator-log.d.ts +78 -0
- package/dist/src/operator-log.d.ts.map +1 -0
- package/dist/src/operator-log.js +102 -0
- package/dist/src/operator-log.js.map +1 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts +10 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js +20 -0
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/types/framework.d.ts +24 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +40 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +199 -1
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/api-admin-gate.test.d.ts +2 -0
- package/dist/test/api-admin-gate.test.d.ts.map +1 -0
- package/dist/test/api-admin-gate.test.js +116 -0
- package/dist/test/api-admin-gate.test.js.map +1 -0
- package/dist/test/channel-label-history.test.d.ts +2 -0
- package/dist/test/channel-label-history.test.d.ts.map +1 -0
- package/dist/test/channel-label-history.test.js +424 -0
- package/dist/test/channel-label-history.test.js.map +1 -0
- package/dist/test/discord-awareness-outbox.test.js +16 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -1
- package/dist/test/event-gate.test.js +44 -0
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/helpers/quiesce-crash-child.d.ts +2 -0
- package/dist/test/helpers/quiesce-crash-child.d.ts.map +1 -0
- package/dist/test/helpers/quiesce-crash-child.js +156 -0
- package/dist/test/helpers/quiesce-crash-child.js.map +1 -0
- package/dist/test/history-module-overview.test.d.ts +2 -0
- package/dist/test/history-module-overview.test.d.ts.map +1 -0
- package/dist/test/history-module-overview.test.js +711 -0
- package/dist/test/history-module-overview.test.js.map +1 -0
- package/dist/test/history-module.test.d.ts +2 -0
- package/dist/test/history-module.test.d.ts.map +1 -0
- package/dist/test/history-module.test.js +595 -0
- package/dist/test/history-module.test.js.map +1 -0
- package/dist/test/host-quiesce.test.d.ts +2 -0
- package/dist/test/host-quiesce.test.d.ts.map +1 -0
- package/dist/test/host-quiesce.test.js +863 -0
- package/dist/test/host-quiesce.test.js.map +1 -0
- package/dist/test/kv-unified-open-flight-retry.test.d.ts +2 -0
- package/dist/test/kv-unified-open-flight-retry.test.d.ts.map +1 -0
- package/dist/test/kv-unified-open-flight-retry.test.js +189 -0
- package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -0
- package/dist/test/kv-unified-wiring.test.js +39 -0
- package/dist/test/kv-unified-wiring.test.js.map +1 -1
- package/dist/test/operator-surgery.test.d.ts +2 -0
- package/dist/test/operator-surgery.test.d.ts.map +1 -0
- package/dist/test/operator-surgery.test.js +398 -0
- package/dist/test/operator-surgery.test.js.map +1 -0
- package/dist/test/puppet-tool-call.test.js +118 -1
- package/dist/test/puppet-tool-call.test.js.map +1 -1
- package/dist/test/puppet-turn-reservation.test.d.ts +2 -0
- package/dist/test/puppet-turn-reservation.test.d.ts.map +1 -0
- package/dist/test/puppet-turn-reservation.test.js +139 -0
- package/dist/test/puppet-turn-reservation.test.js.map +1 -0
- package/dist/test/save-recent-image.test.js +29 -0
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/tool-result-spill.test.js +114 -0
- package/dist/test/tool-result-spill.test.js.map +1 -1
- package/dist/test/workspace-read-text.test.d.ts +2 -0
- package/dist/test/workspace-read-text.test.d.ts.map +1 -0
- package/dist/test/workspace-read-text.test.js +101 -0
- package/dist/test/workspace-read-text.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +110 -0
- package/src/api/server.ts +159 -2
- package/src/api/types.ts +15 -0
- package/src/framework.ts +2196 -115
- package/src/gate/event-gate.ts +47 -4
- package/src/index.ts +5 -1
- package/src/mcpl/channel-registry.ts +495 -2
- package/src/mcpl/server-connection.ts +73 -13
- package/src/modules/history/index.ts +1277 -0
- package/src/modules/history/search-regex-worker.ts +76 -0
- package/src/modules/index.ts +2 -0
- package/src/modules/workspace/index.ts +42 -1
- package/src/modules/workspace/types.ts +5 -0
- package/src/operator-log.ts +132 -0
- package/src/recovery/discord-awareness-outbox.ts +19 -0
- package/src/types/framework.ts +27 -0
- 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 =
|
|
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 =
|
|
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 };
|