letagents 0.12.20 → 0.12.21
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/dist/mcp/local-state/local-chat.js +4 -1
- package/dist/mcp/server/runtime/supervisor-bridge.js +5 -1
- package/dist/mcp/server/runtime/worker-bearer.js +3 -0
- package/dist/shared/activation-routing.js +2 -468
- package/package.json +1 -1
- package/shared/activation-routing.d.mts +139 -0
- package/shared/activation-routing.mjs +468 -0
- package/shared/local-supervised-routing.d.mts +9 -0
- package/shared/local-supervised-routing.mjs +93 -0
- package/shared/message-contracts.d.mts +2 -0
- package/shared/message-contracts.mjs +11 -0
- package/shared/room-api-origin.d.mts +3 -0
- package/shared/room-api-origin.mjs +12 -0
- package/shared/sqlite-thread-routing.d.mts +4 -0
- package/shared/sqlite-thread-routing.mjs +30 -3
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { normalizeRoutingHandle, normalizeRoutingSender, routingIdentityAliases, routingSenderAliasRows, routingSenderAliases, } from "./routing-aliases.mjs";
|
|
2
|
+
import { parsePositivePgIntegerScopedId } from "./message-contracts.mjs";
|
|
3
|
+
/** Send-time human fallback only; never use this to re-route historical reads. */
|
|
4
|
+
export function humanConversationFallback(input) {
|
|
5
|
+
if (input.source !== "browser" || !input.publisherAccountId || input.publisherAgentKey || input.explicitlyAddressed)
|
|
6
|
+
return null;
|
|
7
|
+
const keys = [...new Set(input.registeredAgentKeys)];
|
|
8
|
+
if (keys.length > 0 && keys.length <= 2)
|
|
9
|
+
return { reason: "small_room", agentKeys: keys };
|
|
10
|
+
if (keys.length > 2 && input.recentAgentKey && keys.includes(input.recentAgentKey)) {
|
|
11
|
+
return { reason: "recent_conversation", agentKeys: [input.recentAgentKey] };
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Repository events can contain text written by any external contributor.
|
|
17
|
+
* They remain visible room activity, but their text is never an instruction
|
|
18
|
+
* channel for a managed local worker.
|
|
19
|
+
*/
|
|
20
|
+
export function isUntrustedExternalActivationSource(source) {
|
|
21
|
+
return normalizeSender(source) === "github";
|
|
22
|
+
}
|
|
23
|
+
const NON_AGENT_AT_HANDLES = new Set([
|
|
24
|
+
"charset",
|
|
25
|
+
"container",
|
|
26
|
+
"counter-style",
|
|
27
|
+
"font-face",
|
|
28
|
+
"font-feature-values",
|
|
29
|
+
"font-palette-values",
|
|
30
|
+
"import",
|
|
31
|
+
"keyframes",
|
|
32
|
+
"layer",
|
|
33
|
+
"media",
|
|
34
|
+
"namespace",
|
|
35
|
+
"page",
|
|
36
|
+
"package",
|
|
37
|
+
"property",
|
|
38
|
+
"scope",
|
|
39
|
+
"starting-style",
|
|
40
|
+
"supports",
|
|
41
|
+
"types",
|
|
42
|
+
"viewport",
|
|
43
|
+
]);
|
|
44
|
+
const TASK_OWNER_FOLLOW_UP_PATTERNS = [
|
|
45
|
+
/^(?:ok(?:ay)?|right|cool|great|nice)?[\s,]*(?:try again|retry|rerun|re-run|continue|proceed|go ahead|carry on)\b/,
|
|
46
|
+
/^(?:ok(?:ay)?|right|cool|great|nice)?[\s,]*(?:open|create|make|raise)\s+(?:a\s+)?pr\b/,
|
|
47
|
+
/^(?:ok(?:ay)?|right|cool|great|nice)?[\s,]*(?:push|merge|ship|fix|test|run|update)\s+(?:it|that|this|again|tests?|the\s+tests?|ci)\b/,
|
|
48
|
+
/\b(?:try again|open\s+(?:a\s+)?pr|create\s+(?:a\s+)?pr|make\s+(?:a\s+)?pr|push it|merge it|update it)\b/,
|
|
49
|
+
];
|
|
50
|
+
export function attachAgentMessageActivation(message, identity, context = {}) {
|
|
51
|
+
return {
|
|
52
|
+
...message,
|
|
53
|
+
activation: {
|
|
54
|
+
for_current_agent: decideAgentMessageActivation(message, identity, context),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Send-time receipts are the activation authority. For a snapshot-bearing
|
|
60
|
+
* message, a receipt activates and the absence of one is the durable
|
|
61
|
+
* send-time "silent" — never re-promoted by re-running the router against
|
|
62
|
+
* later task/thread/session state, which would create a second authority.
|
|
63
|
+
* Only messages that predate routing snapshots keep the lazy per-reader
|
|
64
|
+
* decision, so legacy backlog mentions still activate rotated sessions.
|
|
65
|
+
*/
|
|
66
|
+
export function attachAgentMessageActivationsFromReceipts(messages, identity, receiptsMap, snapshotNumbers, context = {}) {
|
|
67
|
+
if (!identity || identity.session_kind !== "worker") {
|
|
68
|
+
return [...messages];
|
|
69
|
+
}
|
|
70
|
+
return messages.map((message) => {
|
|
71
|
+
const msgIdStr = String(message.id ?? "");
|
|
72
|
+
const msgNum = parsePositivePgIntegerScopedId(msgIdStr, "msg");
|
|
73
|
+
const receipt = msgNum !== null ? receiptsMap.get(msgNum) || receiptsMap.get(msgIdStr) : null;
|
|
74
|
+
if (receipt) {
|
|
75
|
+
const reason = receipt.activation_reason;
|
|
76
|
+
return {
|
|
77
|
+
...message,
|
|
78
|
+
activation: {
|
|
79
|
+
for_current_agent: {
|
|
80
|
+
decision: "activate",
|
|
81
|
+
reason: reason || "explicit_mention",
|
|
82
|
+
addressed: true,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// System failure rows are canonical silent control events. A routing
|
|
88
|
+
// snapshot with no receipt must not erase their diagnostic reason.
|
|
89
|
+
if (msgNum !== null
|
|
90
|
+
&& snapshotNumbers.has(msgNum)
|
|
91
|
+
&& normalizeSender(message.source) !== "managed_agent_failure") {
|
|
92
|
+
return {
|
|
93
|
+
...message,
|
|
94
|
+
activation: {
|
|
95
|
+
for_current_agent: {
|
|
96
|
+
decision: "silent",
|
|
97
|
+
reason: "unaddressed",
|
|
98
|
+
addressed: false,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return attachAgentMessageActivation(message, identity, context);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
export function attachAgentMessageActivations(messages, identity, context = {}) {
|
|
107
|
+
if (!identity || identity.session_kind !== "worker") {
|
|
108
|
+
return [...messages];
|
|
109
|
+
}
|
|
110
|
+
return messages.map((message) => attachAgentMessageActivation(message, identity, context));
|
|
111
|
+
}
|
|
112
|
+
export function decideAgentMessageActivation(message, identity, context = {}) {
|
|
113
|
+
if (normalizeSender(message.source) === "managed_agent_failure"
|
|
114
|
+
|| isUntrustedExternalActivationSource(message.source)) {
|
|
115
|
+
return decision("silent", "system_event");
|
|
116
|
+
}
|
|
117
|
+
const messageId = normalizedString(message.id);
|
|
118
|
+
const authoritativeLegacyDecision = context.authoritativeLegacyDecisions?.get(messageId);
|
|
119
|
+
if (authoritativeLegacyDecision)
|
|
120
|
+
return authoritativeLegacyDecision;
|
|
121
|
+
if (context.selfMessageIds !== undefined
|
|
122
|
+
? context.selfMessageIds.has(messageId)
|
|
123
|
+
: senderMatchesIdentity(message.sender, identity)) {
|
|
124
|
+
return decision("silent", "self_message");
|
|
125
|
+
}
|
|
126
|
+
const mentions = extractMentionHandles(message.text);
|
|
127
|
+
if (mentions.some(isBroadcastHandle)) {
|
|
128
|
+
return decision("activate", "broadcast");
|
|
129
|
+
}
|
|
130
|
+
const authoritativeExplicitMentions = context.explicitMentionMessageIds;
|
|
131
|
+
if (authoritativeExplicitMentions !== undefined
|
|
132
|
+
? authoritativeExplicitMentions.has(messageId)
|
|
133
|
+
: mentions.some((mention) => activationIdentityAliases(identity).has(normalizeMentionIdentityHandle(mention)))) {
|
|
134
|
+
return decision("activate", "explicit_mention");
|
|
135
|
+
}
|
|
136
|
+
if (hasBroadcastAddress(message.text)) {
|
|
137
|
+
return decision("activate", "broadcast");
|
|
138
|
+
}
|
|
139
|
+
if (mentions.some(isLikelyAgentMentionHandle)) {
|
|
140
|
+
return decision("silent", "explicit_other_mention");
|
|
141
|
+
}
|
|
142
|
+
const authoritativeThreadParticipantRootIds = context.threadParticipantRootIds;
|
|
143
|
+
const authoritativeReplyTargets = context.replyTargetMessageIds;
|
|
144
|
+
const hasAuthoritativeThreadMembership = isThreadReply(message)
|
|
145
|
+
&& authoritativeThreadParticipantRootIds !== undefined;
|
|
146
|
+
if (authoritativeReplyTargets !== undefined
|
|
147
|
+
? authoritativeReplyTargets.has(messageId)
|
|
148
|
+
: !hasAuthoritativeThreadMembership && senderMatchesIdentity(message.reply_to?.sender, identity)) {
|
|
149
|
+
return decision("activate", "reply_target");
|
|
150
|
+
}
|
|
151
|
+
if (isAgentReplyTarget(message.reply_to) && !isThreadReply(message)) {
|
|
152
|
+
return decision("silent", "other_reply_target");
|
|
153
|
+
}
|
|
154
|
+
if (isThreadReply(message)
|
|
155
|
+
&& (hasAuthoritativeThreadMembership
|
|
156
|
+
? authoritativeThreadParticipantRootIds.has(threadRootId(message))
|
|
157
|
+
: threadParticipantsIncludeIdentity(message, identity))) {
|
|
158
|
+
return decision("activate", "thread_participant");
|
|
159
|
+
}
|
|
160
|
+
const taskOwnerDecision = decideTaskOwnerActivation(message, identity, context);
|
|
161
|
+
if (taskOwnerDecision) {
|
|
162
|
+
return taskOwnerDecision;
|
|
163
|
+
}
|
|
164
|
+
return decision("unclear", "unaddressed");
|
|
165
|
+
}
|
|
166
|
+
function decision(decisionValue, reason) {
|
|
167
|
+
return {
|
|
168
|
+
decision: decisionValue,
|
|
169
|
+
reason,
|
|
170
|
+
addressed: decisionValue === "activate",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function isThreadReply(message) {
|
|
174
|
+
const ownId = normalizedString(message.id);
|
|
175
|
+
const rootId = threadRootId(message);
|
|
176
|
+
return Boolean(ownId && rootId && ownId !== rootId);
|
|
177
|
+
}
|
|
178
|
+
function threadRootId(message) {
|
|
179
|
+
return normalizedString(message.thread_root_id) || normalizedString(message.thread?.root_message_id);
|
|
180
|
+
}
|
|
181
|
+
function isAgentReplyTarget(replyTo) {
|
|
182
|
+
return normalizeSender(replyTo?.source) === "agent";
|
|
183
|
+
}
|
|
184
|
+
function threadParticipantsIncludeIdentity(message, identity) {
|
|
185
|
+
const senders = [
|
|
186
|
+
message.reply_to?.sender,
|
|
187
|
+
message.thread?.latest_reply?.sender,
|
|
188
|
+
...(message.thread?.participants ?? []).map((participant) => participant.sender),
|
|
189
|
+
];
|
|
190
|
+
return senders.some((sender) => senderMatchesIdentity(sender, identity));
|
|
191
|
+
}
|
|
192
|
+
function decideTaskOwnerActivation(message, identity, context) {
|
|
193
|
+
if (!isTaskOwnerFollowUp(message.text)) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const owners = uniqueActiveWorkOwners(context.activeTaskLeases ?? []);
|
|
197
|
+
if (owners.length !== 1) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const owner = owners[0];
|
|
201
|
+
if (senderMatchesLeaseOwner(message.sender, owner)) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
if (leaseOwnerMatchesIdentity(owner, identity)) {
|
|
205
|
+
return decision("activate", "task_owner");
|
|
206
|
+
}
|
|
207
|
+
if (identityOverlapsLeaseOwner(identity, owner)) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
return decision("silent", "task_owner");
|
|
211
|
+
}
|
|
212
|
+
function uniqueActiveWorkOwners(leases) {
|
|
213
|
+
const ownersByKey = new Map();
|
|
214
|
+
for (const lease of leases) {
|
|
215
|
+
if (lease.kind !== "work" || lease.status !== "active")
|
|
216
|
+
continue;
|
|
217
|
+
const key = leaseOwnerKey(lease);
|
|
218
|
+
if (!key)
|
|
219
|
+
continue;
|
|
220
|
+
ownersByKey.set(key, lease);
|
|
221
|
+
}
|
|
222
|
+
return [...ownersByKey.values()];
|
|
223
|
+
}
|
|
224
|
+
function leaseOwnerKey(lease) {
|
|
225
|
+
const sessionId = normalizedString(lease.agent_session_id);
|
|
226
|
+
if (sessionId)
|
|
227
|
+
return `session:${sessionId}`;
|
|
228
|
+
const instanceId = normalizedString(lease.agent_instance_id);
|
|
229
|
+
const agentKey = normalizeSender(lease.agent_key);
|
|
230
|
+
if (instanceId)
|
|
231
|
+
return `instance:${agentKey}:${instanceId}`;
|
|
232
|
+
if (agentKey)
|
|
233
|
+
return `agent:${agentKey}`;
|
|
234
|
+
const actorLabel = normalizeSender(lease.actor_label);
|
|
235
|
+
return actorLabel ? `label:${actorLabel}` : null;
|
|
236
|
+
}
|
|
237
|
+
function leaseOwnerMatchesIdentity(lease, identity) {
|
|
238
|
+
const leaseSessionId = normalizedString(lease.agent_session_id);
|
|
239
|
+
if (leaseSessionId) {
|
|
240
|
+
return leaseSessionId === normalizedString(identity.agent_session_id);
|
|
241
|
+
}
|
|
242
|
+
const leaseInstanceId = normalizedString(lease.agent_instance_id);
|
|
243
|
+
if (leaseInstanceId) {
|
|
244
|
+
return (leaseInstanceId === normalizedString(identity.agent_instance_id) &&
|
|
245
|
+
normalizeSender(lease.agent_key) === normalizeSender(identity.agent_key));
|
|
246
|
+
}
|
|
247
|
+
const leaseAgentKey = normalizeSender(lease.agent_key);
|
|
248
|
+
if (leaseAgentKey) {
|
|
249
|
+
return leaseAgentKey === normalizeSender(identity.agent_key);
|
|
250
|
+
}
|
|
251
|
+
return senderMatchesIdentity(lease.actor_label, identity);
|
|
252
|
+
}
|
|
253
|
+
function senderMatchesLeaseOwner(sender, lease) {
|
|
254
|
+
const normalizedSender = normalizeSender(sender);
|
|
255
|
+
if (!normalizedSender)
|
|
256
|
+
return false;
|
|
257
|
+
return leaseOwnerAliases(lease).has(normalizedSender);
|
|
258
|
+
}
|
|
259
|
+
function identityOverlapsLeaseOwner(identity, lease) {
|
|
260
|
+
const identityAliasesForOwner = aliasesForValues([
|
|
261
|
+
identity.actor_label,
|
|
262
|
+
identity.display_name,
|
|
263
|
+
identity.agent_key,
|
|
264
|
+
identity.agent_instance_id,
|
|
265
|
+
identity.agent_session_id,
|
|
266
|
+
]);
|
|
267
|
+
for (const ownerAlias of leaseOwnerAliases(lease)) {
|
|
268
|
+
if (identityAliasesForOwner.has(ownerAlias)) {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
function leaseOwnerAliases(lease) {
|
|
275
|
+
return aliasesForValues([
|
|
276
|
+
lease.actor_label,
|
|
277
|
+
...String(lease.actor_label || "").split("|"),
|
|
278
|
+
lease.agent_key,
|
|
279
|
+
lease.agent_instance_id,
|
|
280
|
+
lease.agent_session_id,
|
|
281
|
+
]);
|
|
282
|
+
}
|
|
283
|
+
function aliasesForValues(values) {
|
|
284
|
+
const aliases = new Set();
|
|
285
|
+
for (const value of values) {
|
|
286
|
+
const senderAlias = normalizeSender(value);
|
|
287
|
+
if (senderAlias)
|
|
288
|
+
aliases.add(senderAlias);
|
|
289
|
+
const handleAlias = normalizeHandle(value);
|
|
290
|
+
if (handleAlias)
|
|
291
|
+
aliases.add(handleAlias);
|
|
292
|
+
}
|
|
293
|
+
return aliases;
|
|
294
|
+
}
|
|
295
|
+
function senderMatchesIdentity(sender, identity) {
|
|
296
|
+
const normalizedSender = normalizeSender(sender);
|
|
297
|
+
if (!normalizedSender)
|
|
298
|
+
return false;
|
|
299
|
+
const aliases = activationIdentityAliases(identity);
|
|
300
|
+
if (aliases.has(normalizedSender))
|
|
301
|
+
return true;
|
|
302
|
+
return String(sender || "")
|
|
303
|
+
.split("|")
|
|
304
|
+
.some((part) => aliases.has(normalizeSender(part)));
|
|
305
|
+
}
|
|
306
|
+
export function activationIdentityAliases(identity) {
|
|
307
|
+
return routingIdentityAliases(identity);
|
|
308
|
+
}
|
|
309
|
+
/** Canonical aliases materialized from a historical message sender. */
|
|
310
|
+
export function activationSenderAliases(sender, segmentLimit = 16) {
|
|
311
|
+
return routingSenderAliases(sender, segmentLimit);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Resolve identity-bearing addresses against the complete active room
|
|
315
|
+
* population. A display alias is authority only when it names one durable
|
|
316
|
+
* agent key globally; account/provider filtering happens after this step.
|
|
317
|
+
* Full historical sender labels take precedence over their pipe-delimited
|
|
318
|
+
* compatibility segments.
|
|
319
|
+
*/
|
|
320
|
+
export function resolveGloballyAddressedAgentKeys(message, identities) {
|
|
321
|
+
return createGlobalAgentAddressResolver(identities)(message);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Build the room-wide alias authority once, then resolve a page of legacy
|
|
325
|
+
* messages without rebuilding every active worker alias set per message.
|
|
326
|
+
*/
|
|
327
|
+
export function createGlobalAgentAddressResolver(identities, options = {}) {
|
|
328
|
+
const keysByAlias = new Map();
|
|
329
|
+
for (const identity of identities) {
|
|
330
|
+
const key = normalizedString(identity.agent_key);
|
|
331
|
+
if (!key)
|
|
332
|
+
continue;
|
|
333
|
+
for (const alias of activationIdentityAliases(identity)) {
|
|
334
|
+
const keys = keysByAlias.get(alias) ?? new Set();
|
|
335
|
+
keys.add(key);
|
|
336
|
+
keysByAlias.set(alias, keys);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const resolveExplicitMentionKey = (keys) => {
|
|
340
|
+
if (!keys || keys.size === 0)
|
|
341
|
+
return null;
|
|
342
|
+
if (keys.size === 1)
|
|
343
|
+
return keys.values().next().value;
|
|
344
|
+
const preferredMatches = [...keys].filter((key) => options.preferredExplicitMentionAgentKeys?.has(key));
|
|
345
|
+
if (preferredMatches.length !== 1)
|
|
346
|
+
return null;
|
|
347
|
+
const ownerScopes = new Set();
|
|
348
|
+
for (const key of keys) {
|
|
349
|
+
const scope = options.explicitMentionOwnerScopeByAgentKey?.get(key);
|
|
350
|
+
if (!scope)
|
|
351
|
+
return null;
|
|
352
|
+
ownerScopes.add(scope);
|
|
353
|
+
}
|
|
354
|
+
return ownerScopes.size === 1 ? preferredMatches[0] : null;
|
|
355
|
+
};
|
|
356
|
+
return (message) => {
|
|
357
|
+
const mentions = extractMentionHandles(message.text);
|
|
358
|
+
const broadcast = mentions.some(isBroadcastHandle) || hasBroadcastAddress(message.text);
|
|
359
|
+
const hasMention = mentions.some((mention) => !isBroadcastHandle(mention));
|
|
360
|
+
const hasAgentMention = mentions.some(isLikelyAgentMentionHandle);
|
|
361
|
+
const explicitMentionKeys = new Set();
|
|
362
|
+
for (const mention of mentions) {
|
|
363
|
+
if (isBroadcastHandle(mention))
|
|
364
|
+
continue;
|
|
365
|
+
const alias = normalizeMentionIdentityHandle(mention);
|
|
366
|
+
if (!alias)
|
|
367
|
+
continue;
|
|
368
|
+
const resolvedKey = resolveExplicitMentionKey(keysByAlias.get(alias));
|
|
369
|
+
if (resolvedKey)
|
|
370
|
+
explicitMentionKeys.add(resolvedKey);
|
|
371
|
+
}
|
|
372
|
+
const replyTargetKeys = new Set();
|
|
373
|
+
const replyAliases = normalizedString(message.reply_to?.source) === "agent"
|
|
374
|
+
? routingSenderAliasRows(message.reply_to?.sender)
|
|
375
|
+
: [];
|
|
376
|
+
const matchingKeys = (full) => {
|
|
377
|
+
const keys = new Set();
|
|
378
|
+
for (const row of replyAliases) {
|
|
379
|
+
if (row.isFull !== full)
|
|
380
|
+
continue;
|
|
381
|
+
for (const key of keysByAlias.get(row.alias) ?? [])
|
|
382
|
+
keys.add(key);
|
|
383
|
+
}
|
|
384
|
+
return keys;
|
|
385
|
+
};
|
|
386
|
+
const fullMatches = matchingKeys(true);
|
|
387
|
+
const replyMatches = fullMatches.size > 0 ? fullMatches : matchingKeys(false);
|
|
388
|
+
if (replyMatches.size === 1)
|
|
389
|
+
replyTargetKeys.add(replyMatches.values().next().value);
|
|
390
|
+
const senderKeys = new Set();
|
|
391
|
+
const senderAliases = routingSenderAliasRows(message.sender);
|
|
392
|
+
const senderMatchingKeys = (full) => {
|
|
393
|
+
const keys = new Set();
|
|
394
|
+
for (const row of senderAliases) {
|
|
395
|
+
if (row.isFull !== full)
|
|
396
|
+
continue;
|
|
397
|
+
for (const key of keysByAlias.get(row.alias) ?? [])
|
|
398
|
+
keys.add(key);
|
|
399
|
+
}
|
|
400
|
+
return keys;
|
|
401
|
+
};
|
|
402
|
+
const senderFullMatches = senderMatchingKeys(true);
|
|
403
|
+
const senderMatches = senderFullMatches.size > 0
|
|
404
|
+
? senderFullMatches
|
|
405
|
+
: senderMatchingKeys(false);
|
|
406
|
+
if (senderMatches.size === 1)
|
|
407
|
+
senderKeys.add(senderMatches.values().next().value);
|
|
408
|
+
return {
|
|
409
|
+
broadcast,
|
|
410
|
+
hasMention,
|
|
411
|
+
hasAgentMention,
|
|
412
|
+
explicitMentionKeys,
|
|
413
|
+
replyTargetKeys,
|
|
414
|
+
senderKeys,
|
|
415
|
+
};
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/** Shared legacy task-follow-up classifier used by API and desktop overlays. */
|
|
419
|
+
export function isTaskOwnerFollowUpMessageText(text) {
|
|
420
|
+
return isTaskOwnerFollowUp(text);
|
|
421
|
+
}
|
|
422
|
+
function extractMentionHandles(text) {
|
|
423
|
+
const raw = typeof text === "string" ? text : "";
|
|
424
|
+
const mentions = [];
|
|
425
|
+
for (const match of raw.matchAll(/(^|[\s([{:;,])@([A-Za-z0-9][A-Za-z0-9_.:-]*(?:\/[A-Za-z0-9][A-Za-z0-9_.-]*)*)/g)) {
|
|
426
|
+
mentions.push(match[2]);
|
|
427
|
+
}
|
|
428
|
+
return mentions;
|
|
429
|
+
}
|
|
430
|
+
function isBroadcastHandle(handle) {
|
|
431
|
+
const normalized = normalizeHandle(handle);
|
|
432
|
+
return normalized === "agents" || normalized === "everyone" || normalized === "room";
|
|
433
|
+
}
|
|
434
|
+
function isLikelyAgentMentionHandle(handle) {
|
|
435
|
+
const raw = normalizedString(handle);
|
|
436
|
+
if (!raw)
|
|
437
|
+
return false;
|
|
438
|
+
const normalized = raw.toLowerCase();
|
|
439
|
+
const firstSegment = normalized.split("/", 1)[0].replace(/_/g, "-");
|
|
440
|
+
if (normalized.startsWith("agent:"))
|
|
441
|
+
return true;
|
|
442
|
+
if (normalized.includes("/") && normalized === raw)
|
|
443
|
+
return false;
|
|
444
|
+
return !NON_AGENT_AT_HANDLES.has(firstSegment);
|
|
445
|
+
}
|
|
446
|
+
function hasBroadcastAddress(text) {
|
|
447
|
+
const raw = typeof text === "string" ? text.toLowerCase() : "";
|
|
448
|
+
return /\b(everyone|all agents|you guys|both of you|any agent|whoever owns this)\b/.test(raw);
|
|
449
|
+
}
|
|
450
|
+
function isTaskOwnerFollowUp(text) {
|
|
451
|
+
const raw = typeof text === "string" ? text.trim().toLowerCase() : "";
|
|
452
|
+
if (!raw)
|
|
453
|
+
return false;
|
|
454
|
+
return TASK_OWNER_FOLLOW_UP_PATTERNS.some((pattern) => pattern.test(raw));
|
|
455
|
+
}
|
|
456
|
+
function normalizedString(value) {
|
|
457
|
+
return typeof value === "string" ? value.trim() : "";
|
|
458
|
+
}
|
|
459
|
+
function normalizeSender(value) {
|
|
460
|
+
return normalizeRoutingSender(value);
|
|
461
|
+
}
|
|
462
|
+
function normalizeHandle(value) {
|
|
463
|
+
return normalizeRoutingHandle(value);
|
|
464
|
+
}
|
|
465
|
+
function normalizeMentionIdentityHandle(value) {
|
|
466
|
+
const normalized = normalizeHandle(value);
|
|
467
|
+
return normalized.startsWith("agent:") ? normalized.slice("agent:".length) : normalized;
|
|
468
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SqliteRoutingDatabase } from "./sqlite-thread-routing.mjs";
|
|
2
|
+
export type LocalSupervisedMessageRow = {
|
|
3
|
+
room_id: string; number: number; sender: string; text: string; source: string | null;
|
|
4
|
+
publisher_agent_key: string | null; thread_root_number: number | null; reply_to_number: number | null;
|
|
5
|
+
control_authorized: number | null; timestamp: string; sync_key: string | null;
|
|
6
|
+
};
|
|
7
|
+
export function ensureLocalSupervisedRoutingSchema(db: SqliteRoutingDatabase): void;
|
|
8
|
+
export function captureLocalSupervisedRouting(db: SqliteRoutingDatabase, row: LocalSupervisedMessageRow): void;
|
|
9
|
+
export function runLocalSupervisedMessageWrite<T>(db: SqliteRoutingDatabase, roomId: string, threadRootNumber: number | null, work: () => T): Promise<T>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createGlobalAgentAddressResolver, decideAgentMessageActivation, humanConversationFallback } from "./activation-routing.mjs";
|
|
2
|
+
import { readProjectedLocalThreadRoutingAgentKeys, ensureRequestedRootsProjected, runLocalSqliteWriteTransactionAsync, LocalThreadRoutingProjectionChangedError } from "./sqlite-thread-routing.mjs";
|
|
3
|
+
import { parseSupervisedReplySourceNumber } from "./message-contracts.mjs";
|
|
4
|
+
export function ensureLocalSupervisedRoutingSchema(db) {
|
|
5
|
+
db.exec(`CREATE TABLE IF NOT EXISTS local_supervisor_message_routes (
|
|
6
|
+
room_id TEXT NOT NULL, message_id TEXT NOT NULL, routes_json TEXT NOT NULL,
|
|
7
|
+
PRIMARY KEY(room_id, message_id)
|
|
8
|
+
) STRICT`);
|
|
9
|
+
}
|
|
10
|
+
function recentRecipient(db, message) {
|
|
11
|
+
const recent = db.prepare(`SELECT * FROM local_chat_messages WHERE room_id=? AND number<?
|
|
12
|
+
ORDER BY number DESC LIMIT 50`).all(message.room_id, message.number)
|
|
13
|
+
.filter(row => Date.parse(String(row.timestamp)) >= Date.parse(message.timestamp) - 30 * 60_000);
|
|
14
|
+
const human = recent.find(row => row.source === "browser" && !row.publisher_agent_key
|
|
15
|
+
&& row.control_authorized === 1 && row.thread_root_number === null);
|
|
16
|
+
if (!human)
|
|
17
|
+
return null;
|
|
18
|
+
const captured = db.prepare("SELECT routes_json FROM local_supervisor_message_routes WHERE room_id=? AND message_id=?")
|
|
19
|
+
.get(message.room_id, `msg_${human.number}`);
|
|
20
|
+
if (!captured)
|
|
21
|
+
return null;
|
|
22
|
+
const recipients = Object.entries(JSON.parse(String(captured.routes_json)))
|
|
23
|
+
.filter(([, route]) => route.decision === "activate").map(([key]) => key);
|
|
24
|
+
if (recipients.length === 1)
|
|
25
|
+
return recipients[0];
|
|
26
|
+
const answered = new Set();
|
|
27
|
+
for (const reply of recent) {
|
|
28
|
+
const key = String(reply.publisher_agent_key || "");
|
|
29
|
+
const prefix = `local-supervised:${key}:`;
|
|
30
|
+
const receipt = String(reply.sync_key || "");
|
|
31
|
+
if (reply.source === "agent" && Number(reply.number) > Number(human.number)
|
|
32
|
+
&& recipients.includes(key) && (reply.reply_to_number === human.number
|
|
33
|
+
|| (receipt.startsWith(prefix)
|
|
34
|
+
&& parseSupervisedReplySourceNumber(receipt.slice(prefix.length)) === human.number)))
|
|
35
|
+
answered.add(key);
|
|
36
|
+
}
|
|
37
|
+
return answered.size === 1 ? [...answered][0] : null;
|
|
38
|
+
}
|
|
39
|
+
/** Capture all recipients atomically with the message, including the empty-room case. */
|
|
40
|
+
export function captureLocalSupervisedRouting(db, row) {
|
|
41
|
+
const reply = row.reply_to_number ? db.prepare("SELECT * FROM local_chat_messages WHERE room_id=? AND number=?")
|
|
42
|
+
.get(row.room_id, row.reply_to_number) : null;
|
|
43
|
+
const registered = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='local_supervisor_grants'").get();
|
|
44
|
+
const identities = registered
|
|
45
|
+
? db.prepare("SELECT agent_key,display_name FROM local_supervisor_grants WHERE room_id=? AND revoked_at IS NULL ORDER BY entry_id")
|
|
46
|
+
.all(row.room_id).map(grant => ({ agent_key: String(grant.agent_key), display_name: String(grant.display_name),
|
|
47
|
+
actor_label: String(grant.display_name), agent_instance_id: null, agent_session_id: null, session_kind: "worker" })) : [];
|
|
48
|
+
const id = `msg_${row.number}`;
|
|
49
|
+
const rootId = `msg_${row.thread_root_number ?? row.number}`;
|
|
50
|
+
const threadReply = row.thread_root_number !== null && row.thread_root_number !== row.number;
|
|
51
|
+
const message = { id, text: row.text, sender: row.sender, source: row.source,
|
|
52
|
+
thread_root_id: threadReply ? rootId : null, reply_to: reply ? { id: `msg_${reply.number}`, sender: reply.sender } : null };
|
|
53
|
+
const address = createGlobalAgentAddressResolver(identities)(message);
|
|
54
|
+
const participants = threadReply ? readProjectedLocalThreadRoutingAgentKeys(db, row.room_id, [row.thread_root_number], identities.map(identity => ({ agentKey: identity.agent_key, displayName: identity.display_name, actorLabel: identity.actor_label })))
|
|
55
|
+
.get(row.thread_root_number) ?? new Set() : new Set();
|
|
56
|
+
const fallback = humanConversationFallback({ source: row.source,
|
|
57
|
+
publisherAccountId: row.control_authorized === 1 ? "local-owner" : null, publisherAgentKey: row.publisher_agent_key,
|
|
58
|
+
explicitlyAddressed: address.broadcast || address.hasAgentMention || threadReply || Boolean(reply),
|
|
59
|
+
registeredAgentKeys: identities.map(identity => identity.agent_key),
|
|
60
|
+
recentAgentKey: identities.length > 2 ? recentRecipient(db, row) : null });
|
|
61
|
+
const routes = {};
|
|
62
|
+
for (const identity of identities) {
|
|
63
|
+
const key = identity.agent_key;
|
|
64
|
+
let decision = decideAgentMessageActivation(message, identity, {
|
|
65
|
+
selfMessageIds: new Set(row.source === "agent" && row.publisher_agent_key === key ? [id] : []),
|
|
66
|
+
explicitMentionMessageIds: new Set(address.explicitMentionKeys.has(key) ? [id] : []),
|
|
67
|
+
replyTargetMessageIds: new Set(!threadReply && (reply?.publisher_agent_key
|
|
68
|
+
? reply.publisher_agent_key === key : address.replyTargetKeys.has(key)) ? [id] : []),
|
|
69
|
+
threadParticipantRootIds: new Set(participants.has(key) ? [rootId] : []),
|
|
70
|
+
});
|
|
71
|
+
if (row.source === "agent" && !row.publisher_agent_key)
|
|
72
|
+
decision = { decision: "silent", reason: "unaddressed", addressed: false };
|
|
73
|
+
if (decision.reason === "unaddressed" && fallback?.agentKeys.includes(key)) {
|
|
74
|
+
decision = { decision: "activate", reason: fallback.reason, addressed: true };
|
|
75
|
+
}
|
|
76
|
+
routes[key] = decision;
|
|
77
|
+
}
|
|
78
|
+
db.prepare("INSERT INTO local_supervisor_message_routes(room_id,message_id,routes_json) VALUES(?,?,?)")
|
|
79
|
+
.run(row.room_id, id, JSON.stringify(routes));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Repair outside the write lock; a concurrent invalidation rolls back the entire send. */
|
|
83
|
+
export async function runLocalSupervisedMessageWrite(db, roomId, threadRootNumber, work) {
|
|
84
|
+
const deadline = performance.now() + 2_000;
|
|
85
|
+
for (;;) {
|
|
86
|
+
if (threadRootNumber) await ensureRequestedRootsProjected(db, roomId, [threadRootNumber]);
|
|
87
|
+
try {
|
|
88
|
+
return await runLocalSqliteWriteTransactionAsync(db, work);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (!(error instanceof LocalThreadRoutingProjectionChangedError) || performance.now() >= deadline) throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -107,3 +107,14 @@ export function parseAccountAgentRoutingEnvelope(routing) {
|
|
|
107
107
|
controlAuthorized: routing.control_authorized === true,
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
|
+
|
|
111
|
+
/** Only daemon publication identities can link an answer to its source receipt. */
|
|
112
|
+
export function parseSupervisedReplySourceNumber(clientMessageId) {
|
|
113
|
+
if (!clientMessageId) return null;
|
|
114
|
+
const parts = clientMessageId.split(":");
|
|
115
|
+
if (parts[0] !== "supervised-room" || parts.at(-2) !== "reply" || parts.at(-1) !== "v1") return null;
|
|
116
|
+
const body = parts.slice(1, -2);
|
|
117
|
+
if (body.length !== 2 && body.length !== 3) return null;
|
|
118
|
+
const source = body.at(-1);
|
|
119
|
+
return source ? parsePositivePgIntegerScopedId(source, "msg") : null;
|
|
120
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** An in-process storage adapter, never an HTTP endpoint or cloud credential audience. */
|
|
2
|
+
export const LOCAL_ROOM_API_ORIGIN = "letagents-local://rooms";
|
|
3
|
+
|
|
4
|
+
export function isLocalRoomApi(value) {
|
|
5
|
+
return value === LOCAL_ROOM_API_ORIGIN;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Preserve the existing HTTP normalization while retaining the explicit local authority. */
|
|
9
|
+
export function roomApiOrigin(value) {
|
|
10
|
+
if (isLocalRoomApi(value)) return LOCAL_ROOM_API_ORIGIN;
|
|
11
|
+
return new URL(value).origin;
|
|
12
|
+
}
|
|
@@ -70,3 +70,7 @@ export function getLocalThreadRoutingAgentKeysForRoots(
|
|
|
70
70
|
signal?: AbortSignal;
|
|
71
71
|
},
|
|
72
72
|
): Promise<Map<number, Set<string>>>;
|
|
73
|
+
|
|
74
|
+
export function ensureRequestedRootsProjected(database: SqliteRoutingDatabase, roomId: string, rootNumbers: readonly number[], options?: { foregroundTimeBudgetMs?: number; scheduleOnTimeout?: boolean; signal?: AbortSignal }): Promise<void>;
|
|
75
|
+
export function readProjectedLocalThreadRoutingAgentKeys(database: SqliteRoutingDatabase, roomId: string, rootNumbers: readonly number[], identities: readonly RoutingIdentityLike[]): Map<number, Set<string>>;
|
|
76
|
+
export class LocalThreadRoutingProjectionChangedError extends LocalThreadRoutingProjectionUnavailableError {}
|
|
@@ -788,7 +788,7 @@ export function invalidateLocalThreadRoutingRoots(database, roomId, rootNumbersI
|
|
|
788
788
|
}
|
|
789
789
|
}
|
|
790
790
|
|
|
791
|
-
async function ensureRequestedRootsProjected(database, roomId, rootNumbers, options = {}) {
|
|
791
|
+
export async function ensureRequestedRootsProjected(database, roomId, rootNumbers, options = {}) {
|
|
792
792
|
if (rootNumbers.length > LOCAL_THREAD_ROUTING_MAX_REQUESTED_ROOTS) {
|
|
793
793
|
throw new LocalThreadRoutingProjectionUnavailableError();
|
|
794
794
|
}
|
|
@@ -869,6 +869,33 @@ export async function getLocalThreadRoutingAgentKeysForRoots(
|
|
|
869
869
|
if (rootNumbers.length === 0 || identities.length === 0) return new Map();
|
|
870
870
|
await ensureRequestedRootsProjected(database, roomId, rootNumbers, options);
|
|
871
871
|
|
|
872
|
+
const batches = readProjectedThreadRoutingBatches(database, roomId, rootNumbers, identities);
|
|
873
|
+
for (;;) {
|
|
874
|
+
const step = batches.next();
|
|
875
|
+
if (step.done) return step.value;
|
|
876
|
+
await yieldToEventLoop();
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Read prepared projections synchronously inside an existing message transaction. */
|
|
881
|
+
export function readProjectedLocalThreadRoutingAgentKeys(database, roomId, rootNumbers, identities) {
|
|
882
|
+
const statements = requestedRootProjectionStatements(database);
|
|
883
|
+
const rootsJson = JSON.stringify(rootNumbers);
|
|
884
|
+
if (pendingRequestedRoots(statements, roomId, rootsJson).length
|
|
885
|
+
|| statements.invalidated.all(roomId, rootsJson).length) {
|
|
886
|
+
throw new LocalThreadRoutingProjectionChangedError();
|
|
887
|
+
}
|
|
888
|
+
const batches = readProjectedThreadRoutingBatches(database, roomId, rootNumbers, identities);
|
|
889
|
+
for (;;) {
|
|
890
|
+
const step = batches.next();
|
|
891
|
+
if (step.done) return step.value;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export class LocalThreadRoutingProjectionChangedError extends LocalThreadRoutingProjectionUnavailableError {}
|
|
896
|
+
|
|
897
|
+
function* readProjectedThreadRoutingBatches(database, roomId, rootNumbers, identities) {
|
|
898
|
+
if (!rootNumbers.length || !identities.length) return new Map();
|
|
872
899
|
const keysByHash = new Map();
|
|
873
900
|
const durableKeysByHash = new Map();
|
|
874
901
|
for (const identity of identities) {
|
|
@@ -938,7 +965,7 @@ export async function getLocalThreadRoutingAgentKeysForRoots(
|
|
|
938
965
|
keys.add(agentKey);
|
|
939
966
|
result.set(root, keys);
|
|
940
967
|
}
|
|
941
|
-
|
|
968
|
+
yield;
|
|
942
969
|
}
|
|
943
970
|
|
|
944
971
|
const aliasInputs = [];
|
|
@@ -1032,7 +1059,7 @@ export async function getLocalThreadRoutingAgentKeysForRoots(
|
|
|
1032
1059
|
keys.add(agentKey);
|
|
1033
1060
|
result.set(root, keys);
|
|
1034
1061
|
}
|
|
1035
|
-
|
|
1062
|
+
yield;
|
|
1036
1063
|
}
|
|
1037
1064
|
return result;
|
|
1038
1065
|
}
|