@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
|
@@ -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 =
|
|
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 =
|
|
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 };
|