sentinelayer-cli 0.9.0 → 0.9.2
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/package.json +1 -1
- package/src/agents/backend/tools/timeout-audit.js +33 -17
- package/src/agents/devtestbot/runner.js +11 -5
- package/src/commands/session.js +565 -32
- package/src/legacy-cli.js +1 -1
- package/src/scan/generator.js +1 -1
- package/src/session/coordination-guidance.js +2 -1
- package/src/session/event-identity.js +139 -0
- package/src/session/listener.js +96 -2
- package/src/session/live-source.js +11 -2
- package/src/session/mentions.js +130 -0
- package/src/session/remote-hydrate.js +223 -8
- package/src/session/store.js +63 -0
- package/src/session/stream.js +17 -7
- package/src/session/sync.js +375 -26
- package/src/session/title-sync.js +107 -0
package/src/legacy-cli.js
CHANGED
|
@@ -2316,7 +2316,7 @@ jobs:
|
|
|
2316
2316
|
fi
|
|
2317
2317
|
- name: Run Omar Gate
|
|
2318
2318
|
id: omar
|
|
2319
|
-
uses: mrrCarter/sentinelayer-v1-action@
|
|
2319
|
+
uses: mrrCarter/sentinelayer-v1-action@b13504565105b2496c5b1dbb7a3e9bf914c2a9f8
|
|
2320
2320
|
with:
|
|
2321
2321
|
sentinelayer_token: \${{ secrets.${normalizedSecret} }}${specIdBindingLine}
|
|
2322
2322
|
scan_mode: \${{ github.event_name == 'workflow_dispatch' && inputs.scan_mode || 'deep' }}
|
package/src/scan/generator.js
CHANGED
|
@@ -2,7 +2,7 @@ import YAML from "yaml";
|
|
|
2
2
|
|
|
3
3
|
export const DEFAULT_SCAN_WORKFLOW_PATH = ".github/workflows/omar-gate.yml";
|
|
4
4
|
export const DEFAULT_SCAN_SECRET_NAME = "SENTINELAYER_TOKEN";
|
|
5
|
-
export const SENTINELAYER_ACTION_REF = "mrrCarter/sentinelayer-v1-action@
|
|
5
|
+
export const SENTINELAYER_ACTION_REF = "mrrCarter/sentinelayer-v1-action@b13504565105b2496c5b1dbb7a3e9bf914c2a9f8";
|
|
6
6
|
export const SUPPORTED_E2E_HINTS = Object.freeze(["auto", "yes", "no"]);
|
|
7
7
|
export const SUPPORTED_PLAYWRIGHT_MODES = Object.freeze(["auto", "off", "baseline", "audit"]);
|
|
8
8
|
|
|
@@ -2,9 +2,10 @@ export const COORDINATION_GUIDANCE_TITLE = "Multi-Agent Coordination Protocol";
|
|
|
2
2
|
|
|
3
3
|
export const COORDINATION_ETIQUETTE_ITEMS = Object.freeze([
|
|
4
4
|
"Find the recent Senti session for this codebase: run `sl session list --path .` and `sl session list --remote --path .`; join the right room with `sl session join <id> --name <your-name> --role coder`.",
|
|
5
|
+
"When you have an agent grant, post agent updates with `sl session post-agent <id> \"status: <update>\" --agent <your-agent-id>` so they render as the agent, not the human relay.",
|
|
5
6
|
"Before implementation, post a short plan and file claims with `sl session say <id> \"plan: <scope>; files: <paths>\"`.",
|
|
6
7
|
"Claim shared files before editing with `lock: <file> - <intent>` and release them with `unlock: <file> - done`.",
|
|
7
|
-
"Run a background listener for replies: `sl session listen --session <id> --agent <your-name> --interval 60 --emit ndjson`;
|
|
8
|
+
"Run a background listener for replies: `sl session listen --session <id> --agent <your-name> --interval 60 --active-interval 5 --emit ndjson`; this idles at 60s and switches to 5s after human activity. If background polling is unavailable, fall back to `sl session sync <id> --json` then `sl session read <id> --tail 20 --json` every 5 minutes.",
|
|
8
9
|
"Run `sl review --diff` after each finished file or PR-ready diff and post the result summary back to the session.",
|
|
9
10
|
"Post findings through `sl session say <id> \"finding: [P2] <title> in <file>:<line>\"` with enough context for a peer to act.",
|
|
10
11
|
"Ask for help in-session instead of stopping on unexpected file changes, blocked context, or ambiguous ownership.",
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
function keyString(value) {
|
|
2
|
+
return String(value || "").trim();
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function timestampKey(...values) {
|
|
6
|
+
for (const value of values) {
|
|
7
|
+
const normalized = keyString(value);
|
|
8
|
+
if (!normalized) continue;
|
|
9
|
+
const epoch = Date.parse(normalized);
|
|
10
|
+
if (Number.isFinite(epoch)) {
|
|
11
|
+
return new Date(epoch).toISOString();
|
|
12
|
+
}
|
|
13
|
+
return normalized;
|
|
14
|
+
}
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stableJsonValue(value) {
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
return value.map((item) => stableJsonValue(item));
|
|
21
|
+
}
|
|
22
|
+
if (value && typeof value === "object") {
|
|
23
|
+
return Object.fromEntries(
|
|
24
|
+
Object.entries(value)
|
|
25
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
26
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
27
|
+
.map(([key, entryValue]) => [key, stableJsonValue(entryValue)])
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stableStringify(value) {
|
|
34
|
+
return JSON.stringify(stableJsonValue(value));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sessionEventIdentityKeys(event = {}) {
|
|
38
|
+
if (!event || typeof event !== "object") return [];
|
|
39
|
+
const keys = [];
|
|
40
|
+
const id = keyString(event.id);
|
|
41
|
+
if (id) {
|
|
42
|
+
keys.push(`id:${id}`);
|
|
43
|
+
}
|
|
44
|
+
if (typeof event.cursor === "string" && event.cursor.trim()) {
|
|
45
|
+
keys.push(`cursor:${event.cursor.trim()}`);
|
|
46
|
+
}
|
|
47
|
+
if (typeof event.eventId === "string" && event.eventId.trim()) {
|
|
48
|
+
keys.push(`event:${event.eventId.trim()}`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof event.idempotencyToken === "string" && event.idempotencyToken.trim()) {
|
|
51
|
+
keys.push(`idempotency:${event.idempotencyToken.trim()}`);
|
|
52
|
+
}
|
|
53
|
+
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
|
|
54
|
+
const messageId = typeof payload.messageId === "string" ? payload.messageId.trim() : "";
|
|
55
|
+
if (messageId) {
|
|
56
|
+
keys.push(`message:${messageId}`);
|
|
57
|
+
}
|
|
58
|
+
const timestamp = timestampKey(event.ts, event.timestamp, event.at);
|
|
59
|
+
const hasPayloadSignal = Object.keys(payload).length > 0;
|
|
60
|
+
const hasFingerprintSignal =
|
|
61
|
+
id || messageId || keyString(event.eventId) || keyString(event.idempotencyToken) ||
|
|
62
|
+
keyString(event.agent?.id || event.agentId) || timestamp || hasPayloadSignal;
|
|
63
|
+
if (hasFingerprintSignal) {
|
|
64
|
+
try {
|
|
65
|
+
keys.push(`fingerprint:${stableStringify({
|
|
66
|
+
event: event.event || event.type || "",
|
|
67
|
+
id,
|
|
68
|
+
eventId: keyString(event.eventId),
|
|
69
|
+
idempotencyToken: keyString(event.idempotencyToken),
|
|
70
|
+
agent: event.agent?.id || event.agentId || "",
|
|
71
|
+
payload,
|
|
72
|
+
ts: timestamp,
|
|
73
|
+
})}`);
|
|
74
|
+
} catch {
|
|
75
|
+
// Best-effort duplicate suppression only.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const message = keyString(payload.message || payload.text || payload.body);
|
|
79
|
+
if (message) {
|
|
80
|
+
try {
|
|
81
|
+
keys.push(`content:${stableStringify({
|
|
82
|
+
event: keyString(event.event || event.type),
|
|
83
|
+
agent: keyString(event.agent?.id || event.agentId || payload.agentId || payload.authorId),
|
|
84
|
+
payload: {
|
|
85
|
+
channel: keyString(payload.channel),
|
|
86
|
+
clientKind: keyString(payload.clientKind),
|
|
87
|
+
message,
|
|
88
|
+
source: keyString(payload.source),
|
|
89
|
+
to: payload.to || payload.recipient || payload.mentions || null,
|
|
90
|
+
},
|
|
91
|
+
ts: timestampKey(event.ts, event.timestamp, event.at),
|
|
92
|
+
})}`);
|
|
93
|
+
} catch {
|
|
94
|
+
// Best-effort duplicate suppression only.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return keys;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function sessionEventHasKnownIdentity(event = {}, knownKeys = new Set()) {
|
|
101
|
+
const keys = sessionEventIdentityKeys(event);
|
|
102
|
+
return keys.length > 0 && keys.some((key) => knownKeys.has(key));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function addSessionEventIdentityKeys(knownKeys, event = {}) {
|
|
106
|
+
for (const key of sessionEventIdentityKeys(event)) {
|
|
107
|
+
knownKeys.add(key);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function dedupeSessionEvents(events = []) {
|
|
112
|
+
const normalizedEvents = Array.isArray(events) ? events : [];
|
|
113
|
+
const deduped = [];
|
|
114
|
+
const indexByKey = new Map();
|
|
115
|
+
|
|
116
|
+
for (const event of normalizedEvents) {
|
|
117
|
+
const keys = sessionEventIdentityKeys(event);
|
|
118
|
+
const existingIndexes = keys
|
|
119
|
+
.map((key) => indexByKey.get(key))
|
|
120
|
+
.filter((index) => Number.isInteger(index) && index >= 0);
|
|
121
|
+
const existingIndex = existingIndexes.length > 0 ? Math.min(...existingIndexes) : -1;
|
|
122
|
+
|
|
123
|
+
if (existingIndex >= 0) {
|
|
124
|
+
deduped[existingIndex] = event;
|
|
125
|
+
for (const key of keys) {
|
|
126
|
+
indexByKey.set(key, existingIndex);
|
|
127
|
+
}
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const nextIndex = deduped.length;
|
|
132
|
+
deduped.push(event);
|
|
133
|
+
for (const key of keys) {
|
|
134
|
+
indexByKey.set(key, nextIndex);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return deduped;
|
|
139
|
+
}
|
package/src/session/listener.js
CHANGED
|
@@ -13,6 +13,10 @@ const BROADCAST_RECIPIENTS = new Set([
|
|
|
13
13
|
"all-agents",
|
|
14
14
|
]);
|
|
15
15
|
|
|
16
|
+
const DEFAULT_ACTIVE_INTERVAL_SECONDS = 5;
|
|
17
|
+
const DEFAULT_ACTIVE_WINDOW_SECONDS = 300;
|
|
18
|
+
const MAX_CLOCK_SKEW_MS = 60_000;
|
|
19
|
+
|
|
16
20
|
function normalizeString(value) {
|
|
17
21
|
return String(value || "").trim();
|
|
18
22
|
}
|
|
@@ -127,6 +131,70 @@ function eventTimestampMs(event = {}) {
|
|
|
127
131
|
return 0;
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
function normalizeLower(value) {
|
|
135
|
+
return normalizeString(value).toLowerCase();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isHumanMarker(value) {
|
|
139
|
+
const raw = normalizeLower(value);
|
|
140
|
+
if (!raw) return false;
|
|
141
|
+
if (["human", "user", "operator"].includes(raw)) return true;
|
|
142
|
+
const comparable = normalizeComparableId(raw);
|
|
143
|
+
return Boolean(
|
|
144
|
+
comparable === "human" ||
|
|
145
|
+
comparable === "user" ||
|
|
146
|
+
comparable === "operator" ||
|
|
147
|
+
comparable.startsWith("human-") ||
|
|
148
|
+
comparable.startsWith("user-")
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isHumanAuthoredEvent(event = {}) {
|
|
153
|
+
if (!isPlainObject(event)) return false;
|
|
154
|
+
const payload = isPlainObject(event.payload) ? event.payload : {};
|
|
155
|
+
const agent = isPlainObject(event.agent) ? event.agent : {};
|
|
156
|
+
const markerCandidates = [
|
|
157
|
+
payload.source,
|
|
158
|
+
payload.authorType,
|
|
159
|
+
payload.senderType,
|
|
160
|
+
payload.model,
|
|
161
|
+
payload.role,
|
|
162
|
+
event.source,
|
|
163
|
+
event.authorType,
|
|
164
|
+
event.senderType,
|
|
165
|
+
event.model,
|
|
166
|
+
event.role,
|
|
167
|
+
agent.model,
|
|
168
|
+
agent.role,
|
|
169
|
+
];
|
|
170
|
+
if (markerCandidates.some(isHumanMarker)) return true;
|
|
171
|
+
|
|
172
|
+
const idCandidates = [
|
|
173
|
+
agent.id,
|
|
174
|
+
event.agentId,
|
|
175
|
+
event.authorId,
|
|
176
|
+
event.senderId,
|
|
177
|
+
payload.agentId,
|
|
178
|
+
payload.authorId,
|
|
179
|
+
payload.senderId,
|
|
180
|
+
];
|
|
181
|
+
return idCandidates.some(isHumanMarker);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function humanActivityTimestampMs(event = {}, nowMs = Date.now()) {
|
|
185
|
+
if (!isHumanAuthoredEvent(event)) return 0;
|
|
186
|
+
return eventTimestampMs(event) || nowMs;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isRecentActivity(activityMs, nowMs, windowMs) {
|
|
190
|
+
return (
|
|
191
|
+
Number.isFinite(activityMs) &&
|
|
192
|
+
activityMs > 0 &&
|
|
193
|
+
activityMs <= nowMs + MAX_CLOCK_SKEW_MS &&
|
|
194
|
+
nowMs - activityMs <= windowMs
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
130
198
|
/**
|
|
131
199
|
* Poll session events in the background and emit only events addressed to
|
|
132
200
|
* the current agent or broadcast to everyone. The loop advances its cursor
|
|
@@ -138,6 +206,8 @@ export async function listenSessionEvents({
|
|
|
138
206
|
targetPath = process.cwd(),
|
|
139
207
|
agentId = "cli-user",
|
|
140
208
|
intervalSeconds = 60,
|
|
209
|
+
activeIntervalSeconds = DEFAULT_ACTIVE_INTERVAL_SECONDS,
|
|
210
|
+
activeWindowSeconds = DEFAULT_ACTIVE_WINDOW_SECONDS,
|
|
141
211
|
limit = 200,
|
|
142
212
|
since = undefined,
|
|
143
213
|
replay = false,
|
|
@@ -170,8 +240,16 @@ export async function listenSessionEvents({
|
|
|
170
240
|
let lastReason = "";
|
|
171
241
|
const maxPollCount = normalizePositiveInteger(maxPolls, 0);
|
|
172
242
|
const pollLimit = normalizePositiveInteger(limit, 200);
|
|
173
|
-
const
|
|
243
|
+
const idleSleepMs = Math.max(1, normalizePositiveInteger(intervalSeconds, 60)) * 1000;
|
|
244
|
+
const activeSleepMs =
|
|
245
|
+
Math.max(1, normalizePositiveInteger(activeIntervalSeconds, DEFAULT_ACTIVE_INTERVAL_SECONDS)) *
|
|
246
|
+
1000;
|
|
247
|
+
const activeWindowMs =
|
|
248
|
+
Math.max(1, normalizePositiveInteger(activeWindowSeconds, DEFAULT_ACTIVE_WINDOW_SECONDS)) *
|
|
249
|
+
1000;
|
|
174
250
|
const startedAtMs = Number(_nowMs()) || Date.now();
|
|
251
|
+
let lastHumanActivityMs = 0;
|
|
252
|
+
let lastSleepMs = 0;
|
|
175
253
|
|
|
176
254
|
while (!signal?.aborted) {
|
|
177
255
|
pollCount += 1;
|
|
@@ -184,6 +262,13 @@ export async function listenSessionEvents({
|
|
|
184
262
|
if (result?.ok) {
|
|
185
263
|
lastReason = "";
|
|
186
264
|
const events = Array.isArray(result.events) ? result.events : [];
|
|
265
|
+
const observedAtMs = Number(_nowMs()) || Date.now();
|
|
266
|
+
for (const event of events) {
|
|
267
|
+
const activityMs = humanActivityTimestampMs(event, observedAtMs);
|
|
268
|
+
if (isRecentActivity(activityMs, observedAtMs, activeWindowMs)) {
|
|
269
|
+
lastHumanActivityMs = Math.max(lastHumanActivityMs, activityMs);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
187
272
|
const shouldEmitBatch = primed || Boolean(replay);
|
|
188
273
|
for (const event of events) {
|
|
189
274
|
if (!eventMatchesAgent(event, normalizedAgentId)) continue;
|
|
@@ -213,8 +298,12 @@ export async function listenSessionEvents({
|
|
|
213
298
|
}
|
|
214
299
|
|
|
215
300
|
if (maxPollCount > 0 && pollCount >= maxPollCount) break;
|
|
301
|
+
const sleepAtMs = Number(_nowMs()) || Date.now();
|
|
302
|
+
const humanActive = isRecentActivity(lastHumanActivityMs, sleepAtMs, activeWindowMs);
|
|
303
|
+
const nextSleepMs = humanActive ? Math.min(idleSleepMs, activeSleepMs) : idleSleepMs;
|
|
304
|
+
lastSleepMs = nextSleepMs;
|
|
216
305
|
try {
|
|
217
|
-
await _sleep(
|
|
306
|
+
await _sleep(nextSleepMs, { signal });
|
|
218
307
|
} catch (error) {
|
|
219
308
|
if (shouldAbort(error, signal)) break;
|
|
220
309
|
throw error;
|
|
@@ -231,6 +320,11 @@ export async function listenSessionEvents({
|
|
|
231
320
|
matched,
|
|
232
321
|
emitted,
|
|
233
322
|
persistedCursor,
|
|
323
|
+
idleIntervalSeconds: Math.round(idleSleepMs / 1000),
|
|
324
|
+
activeIntervalSeconds: Math.round(activeSleepMs / 1000),
|
|
325
|
+
activeWindowSeconds: Math.round(activeWindowMs / 1000),
|
|
326
|
+
lastHumanActivityAt: lastHumanActivityMs ? new Date(lastHumanActivityMs).toISOString() : null,
|
|
327
|
+
lastSleepMs,
|
|
234
328
|
reason: lastReason,
|
|
235
329
|
};
|
|
236
330
|
}
|
|
@@ -20,12 +20,20 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
20
20
|
|
|
21
21
|
import { resolveSessionPaths } from "./paths.js";
|
|
22
22
|
import { readStream } from "./stream.js";
|
|
23
|
+
import {
|
|
24
|
+
addSessionEventIdentityKeys,
|
|
25
|
+
dedupeSessionEvents,
|
|
26
|
+
sessionEventHasKnownIdentity,
|
|
27
|
+
sessionEventIdentityKeys,
|
|
28
|
+
} from "./event-identity.js";
|
|
23
29
|
|
|
24
30
|
const DEFAULT_RECONNECT_BACKOFF_MS = 2_000;
|
|
25
31
|
const MAX_RECONNECT_BACKOFF_MS = 30_000;
|
|
26
32
|
|
|
27
33
|
function eventKey(event) {
|
|
28
34
|
if (!event || typeof event !== "object") return null;
|
|
35
|
+
const identityKeys = sessionEventIdentityKeys(event);
|
|
36
|
+
if (identityKeys.length > 0) return identityKeys[0];
|
|
29
37
|
if (event.id) return `id:${event.id}`;
|
|
30
38
|
if (event.eventId) return `id:${event.eventId}`;
|
|
31
39
|
const ts = event.ts || event.timestamp;
|
|
@@ -57,7 +65,7 @@ export async function* watchLocalStream({
|
|
|
57
65
|
|
|
58
66
|
// Replay the tail first so any caller getting the iterator catches
|
|
59
67
|
// up with the in-flight context before live events start arriving.
|
|
60
|
-
const initial = await _readEvents(sessionId, { targetPath, tail: initialTail });
|
|
68
|
+
const initial = dedupeSessionEvents(await _readEvents(sessionId, { targetPath, tail: initialTail }));
|
|
61
69
|
for (const event of initial) {
|
|
62
70
|
const candidate = event.ts || event.timestamp;
|
|
63
71
|
if (candidate) lastTs = candidate;
|
|
@@ -293,7 +301,8 @@ export async function* mergeLiveSources({
|
|
|
293
301
|
if (item.event) {
|
|
294
302
|
const key = eventKey(item.event);
|
|
295
303
|
if (key) {
|
|
296
|
-
if (seen.has(key)) continue;
|
|
304
|
+
if (sessionEventHasKnownIdentity(item.event, seen) || seen.has(key)) continue;
|
|
305
|
+
addSessionEventIdentityKeys(seen, item.event);
|
|
297
306
|
seen.add(key);
|
|
298
307
|
if (seen.size > 5000) {
|
|
299
308
|
// bound memory — older keys roll out
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mention parsing for session events.
|
|
3
|
+
*
|
|
4
|
+
* Carter saw `@codex` and `@claude` in messages but nothing routed them —
|
|
5
|
+
* peers had no signal a message was addressed to them. Senti's daemon
|
|
6
|
+
* already handles `help_request` events and the listener already filters
|
|
7
|
+
* by `to / recipient / recipients / targetAgent / targetAgentId` (see
|
|
8
|
+
* `eventMatchesAgent` in `src/session/sync.js`); we just need to populate
|
|
9
|
+
* one of those fields when a human / agent writes `@<name>`.
|
|
10
|
+
*
|
|
11
|
+
* This module is the canonical, **deterministic**, **pure-function**
|
|
12
|
+
* mention parser. It runs at append-time (`appendToStream`) so every
|
|
13
|
+
* event flowing through the local + remote stream picks up `payload.to`
|
|
14
|
+
* for free. The web sidebar + listener can then highlight or notify
|
|
15
|
+
* without changing their own routing logic.
|
|
16
|
+
*
|
|
17
|
+
* Borrowing pattern (per the build spec — borrow, don't import):
|
|
18
|
+
* - `eventMatchesAgent` style of multi-key recipient (we add to `to`,
|
|
19
|
+
* which the listener already understands)
|
|
20
|
+
* - `senti-naming.js` style of normalize-then-set (lowercase + strip
|
|
21
|
+
* punctuation + cap length)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// `@handle` is one or more identifier characters. We allow letters,
|
|
25
|
+
// digits, `._-` so `@codex-1`, `@human-mrrcarter`, `@claude.verifier`
|
|
26
|
+
// all work. Word boundary on the front avoids matching `you@example.com`.
|
|
27
|
+
const MENTION_RE = /(?:^|[^A-Za-z0-9_.-])@([A-Za-z0-9][A-Za-z0-9._-]{0,63})/g;
|
|
28
|
+
|
|
29
|
+
// Common false positives we should never surface as mentions: email-y
|
|
30
|
+
// remnants and code annotations. We stop matching when the @ is preceded
|
|
31
|
+
// by a non-whitespace alnum (handled by the regex), so this is just for
|
|
32
|
+
// post-filter sanity.
|
|
33
|
+
const RESERVED_HANDLES = new Set(["all", "everyone", "channel", "here"]);
|
|
34
|
+
|
|
35
|
+
function normalizeString(value) {
|
|
36
|
+
return String(value == null ? "" : value).trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract `@handle` mentions from a free-text string.
|
|
41
|
+
*
|
|
42
|
+
* - Returns lowercased handles.
|
|
43
|
+
* - Dedupes preserving first-seen order.
|
|
44
|
+
* - Filters reserved broadcast handles (`@all`, `@everyone`, `@channel`,
|
|
45
|
+
* `@here`) into a separate `broadcast` array — the daemon decides
|
|
46
|
+
* whether broadcasts should fan out or be ignored.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} text
|
|
49
|
+
* @returns {{handles: string[], broadcast: string[]}}
|
|
50
|
+
*/
|
|
51
|
+
export function parseMentions(text) {
|
|
52
|
+
const raw = normalizeString(text);
|
|
53
|
+
if (!raw) return { handles: [], broadcast: [] };
|
|
54
|
+
|
|
55
|
+
const handles = [];
|
|
56
|
+
const broadcast = [];
|
|
57
|
+
const seenHandle = new Set();
|
|
58
|
+
const seenBroadcast = new Set();
|
|
59
|
+
|
|
60
|
+
for (const match of raw.matchAll(MENTION_RE)) {
|
|
61
|
+
const candidate = String(match[1] || "").trim().toLowerCase();
|
|
62
|
+
if (!candidate) continue;
|
|
63
|
+
if (RESERVED_HANDLES.has(candidate)) {
|
|
64
|
+
if (!seenBroadcast.has(candidate)) {
|
|
65
|
+
seenBroadcast.add(candidate);
|
|
66
|
+
broadcast.push(candidate);
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (seenHandle.has(candidate)) continue;
|
|
71
|
+
seenHandle.add(candidate);
|
|
72
|
+
handles.push(candidate);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { handles, broadcast };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Look at an event and, if its payload carries human-readable text and
|
|
80
|
+
* doesn't already have a `to` field, populate `payload.to` from any
|
|
81
|
+
* `@<name>` mentions found in the text. Idempotent: if `to` is already
|
|
82
|
+
* set (caller-supplied), we leave it alone. The original message text
|
|
83
|
+
* is never mutated.
|
|
84
|
+
*
|
|
85
|
+
* Also surfaces the parse result on `payload.mentions = { handles, broadcast }`
|
|
86
|
+
* so dashboards can render a "this message addressed: @x, @y" badge
|
|
87
|
+
* without re-parsing.
|
|
88
|
+
*
|
|
89
|
+
* @template {object} E
|
|
90
|
+
* @param {E} event
|
|
91
|
+
* @returns {E} new event (shallow-merged) — NOT mutated in place
|
|
92
|
+
*/
|
|
93
|
+
export function enrichEventWithMentions(event) {
|
|
94
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) return event;
|
|
95
|
+
const payload = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
|
|
96
|
+
? event.payload
|
|
97
|
+
: null;
|
|
98
|
+
if (!payload) return event;
|
|
99
|
+
|
|
100
|
+
// Already routed by an explicit caller — don't second-guess.
|
|
101
|
+
const hasExplicitTo =
|
|
102
|
+
Array.isArray(payload.to) ||
|
|
103
|
+
Array.isArray(payload.recipients) ||
|
|
104
|
+
typeof payload.recipient === "string" ||
|
|
105
|
+
typeof payload.targetAgent === "string" ||
|
|
106
|
+
typeof payload.targetAgentId === "string";
|
|
107
|
+
|
|
108
|
+
// We pull text from the standard fields the renderer already uses,
|
|
109
|
+
// matching the priority in `Session.tsx:payloadText`.
|
|
110
|
+
const text = normalizeString(payload.message)
|
|
111
|
+
|| normalizeString(payload.text)
|
|
112
|
+
|| normalizeString(payload.detail)
|
|
113
|
+
|| normalizeString(payload.title);
|
|
114
|
+
if (!text) return event;
|
|
115
|
+
|
|
116
|
+
const parsed = parseMentions(text);
|
|
117
|
+
if (parsed.handles.length === 0 && parsed.broadcast.length === 0) {
|
|
118
|
+
return event;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const nextPayload = {
|
|
122
|
+
...payload,
|
|
123
|
+
mentions: { handles: parsed.handles, broadcast: parsed.broadcast },
|
|
124
|
+
};
|
|
125
|
+
if (!hasExplicitTo && parsed.handles.length > 0) {
|
|
126
|
+
nextPayload.to = parsed.handles;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { ...event, payload: nextPayload };
|
|
130
|
+
}
|