sentinelayer-cli 0.8.12 → 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.
Files changed (37) hide show
  1. package/package.json +7 -2
  2. package/src/agents/backend/tools/timeout-audit.js +33 -17
  3. package/src/agents/devtestbot/config/definition.js +100 -0
  4. package/src/agents/devtestbot/config/system-prompt.js +92 -0
  5. package/src/agents/devtestbot/index.js +9 -0
  6. package/src/agents/devtestbot/runner.js +775 -0
  7. package/src/agents/devtestbot/tool.js +707 -0
  8. package/src/commands/legacy-args.js +4 -0
  9. package/src/commands/omargate.js +4 -0
  10. package/src/commands/session.js +960 -159
  11. package/src/commands/swarm.js +11 -2
  12. package/src/guide/generator.js +14 -0
  13. package/src/legacy-cli.js +35 -18
  14. package/src/prompt/generator.js +4 -16
  15. package/src/review/ai-review.js +95 -6
  16. package/src/review/dd-report-email-client.js +148 -0
  17. package/src/review/investor-dd-devtestbot.js +599 -0
  18. package/src/review/investor-dd-orchestrator.js +135 -3
  19. package/src/review/omargate-orchestrator.js +20 -2
  20. package/src/review/persona-prompts.js +34 -1
  21. package/src/review/report.js +61 -2
  22. package/src/scan/generator.js +1 -1
  23. package/src/session/coordination-guidance.js +49 -0
  24. package/src/session/daemon.js +3 -2
  25. package/src/session/event-identity.js +139 -0
  26. package/src/session/listener.js +330 -0
  27. package/src/session/live-source.js +11 -2
  28. package/src/session/mentions.js +130 -0
  29. package/src/session/remote-hydrate.js +223 -8
  30. package/src/session/setup-guides.js +3 -15
  31. package/src/session/store.js +117 -5
  32. package/src/session/stream.js +17 -7
  33. package/src/session/sync.js +375 -26
  34. package/src/session/title-sync.js +107 -0
  35. package/src/spec/generator.js +8 -10
  36. package/src/swarm/registry.js +20 -0
  37. package/src/swarm/runtime.js +139 -1
@@ -0,0 +1,330 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ import { pollSessionEvents } from "./sync.js";
4
+ import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
5
+
6
+ const BROADCAST_RECIPIENTS = new Set([
7
+ "*",
8
+ "all",
9
+ "broadcast",
10
+ "everyone",
11
+ "anyone",
12
+ "agents",
13
+ "all-agents",
14
+ ]);
15
+
16
+ const DEFAULT_ACTIVE_INTERVAL_SECONDS = 5;
17
+ const DEFAULT_ACTIVE_WINDOW_SECONDS = 300;
18
+ const MAX_CLOCK_SKEW_MS = 60_000;
19
+
20
+ function normalizeString(value) {
21
+ return String(value || "").trim();
22
+ }
23
+
24
+ function normalizeComparableId(value) {
25
+ return normalizeString(value)
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9._-]+/g, "-")
28
+ .replace(/^-+|-+$/g, "");
29
+ }
30
+
31
+ function normalizePositiveInteger(value, fallbackValue) {
32
+ if (value === undefined || value === null || String(value).trim() === "") {
33
+ return fallbackValue;
34
+ }
35
+ const normalized = Number(value);
36
+ if (!Number.isFinite(normalized) || normalized <= 0) return fallbackValue;
37
+ return Math.floor(normalized);
38
+ }
39
+
40
+ function isPlainObject(value) {
41
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
42
+ }
43
+
44
+ function addRecipientValue(values, value) {
45
+ if (value === undefined || value === null) return;
46
+ if (Array.isArray(value)) {
47
+ for (const item of value) addRecipientValue(values, item);
48
+ return;
49
+ }
50
+ if (isPlainObject(value)) {
51
+ addRecipientValue(values, value.id || value.agentId || value.name);
52
+ return;
53
+ }
54
+ const raw = normalizeString(value);
55
+ if (!raw) return;
56
+ for (const token of raw.split(/[\s,;]+/g)) {
57
+ const normalized = normalizeString(token);
58
+ if (normalized) values.push(normalized);
59
+ }
60
+ }
61
+
62
+ export function collectSessionEventRecipients(event = {}) {
63
+ const values = [];
64
+ if (!isPlainObject(event)) return values;
65
+ const payload = isPlainObject(event.payload) ? event.payload : {};
66
+ for (const source of [
67
+ event.to,
68
+ event.recipient,
69
+ event.recipients,
70
+ event.targetAgent,
71
+ event.targetAgentId,
72
+ payload.to,
73
+ payload.recipient,
74
+ payload.recipients,
75
+ payload.targetAgent,
76
+ payload.targetAgentId,
77
+ ]) {
78
+ addRecipientValue(values, source);
79
+ }
80
+ return values;
81
+ }
82
+
83
+ export function eventMatchesAgent(event = {}, agentId = "") {
84
+ if (!isPlainObject(event)) return false;
85
+ const normalizedAgentId = normalizeComparableId(agentId);
86
+ if (!normalizedAgentId) return false;
87
+
88
+ const payload = isPlainObject(event.payload) ? event.payload : {};
89
+ if (event.broadcast === true || payload.broadcast === true) return true;
90
+
91
+ const recipients = collectSessionEventRecipients(event);
92
+ if (recipients.length === 0) return true;
93
+
94
+ for (const recipient of recipients) {
95
+ const rawRecipient = normalizeString(recipient).toLowerCase();
96
+ if (BROADCAST_RECIPIENTS.has(rawRecipient)) return true;
97
+ const normalizedRecipient = normalizeComparableId(recipient);
98
+ if (!normalizedRecipient) continue;
99
+ if (BROADCAST_RECIPIENTS.has(normalizedRecipient)) return true;
100
+ if (normalizedRecipient === normalizedAgentId) return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ export function listenCursorSuffix(agentId = "") {
106
+ return `listen-${normalizeComparableId(agentId) || "agent"}`;
107
+ }
108
+
109
+ async function defaultSleep(ms, { signal } = {}) {
110
+ await delay(ms, undefined, { signal });
111
+ }
112
+
113
+ function shouldAbort(error, signal) {
114
+ return Boolean(signal?.aborted || error?.name === "AbortError" || error?.code === "ABORT_ERR");
115
+ }
116
+
117
+ function cursorFromEvents(events = [], fallbackCursor = null) {
118
+ let cursor = normalizeString(fallbackCursor) || null;
119
+ for (const event of events) {
120
+ const candidate = normalizeString(event?.cursor);
121
+ if (candidate) cursor = candidate;
122
+ }
123
+ return cursor;
124
+ }
125
+
126
+ function eventTimestampMs(event = {}) {
127
+ for (const key of ["ts", "timestamp", "createdAt", "at"]) {
128
+ const epoch = Date.parse(normalizeString(event?.[key]));
129
+ if (Number.isFinite(epoch)) return epoch;
130
+ }
131
+ return 0;
132
+ }
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
+
198
+ /**
199
+ * Poll session events in the background and emit only events addressed to
200
+ * the current agent or broadcast to everyone. The loop advances its cursor
201
+ * across non-matching events so direct listeners do not replay unrelated
202
+ * traffic forever.
203
+ */
204
+ export async function listenSessionEvents({
205
+ sessionId,
206
+ targetPath = process.cwd(),
207
+ agentId = "cli-user",
208
+ intervalSeconds = 60,
209
+ activeIntervalSeconds = DEFAULT_ACTIVE_INTERVAL_SECONDS,
210
+ activeWindowSeconds = DEFAULT_ACTIVE_WINDOW_SECONDS,
211
+ limit = 200,
212
+ since = undefined,
213
+ replay = false,
214
+ maxPolls = null,
215
+ signal,
216
+ onEvent = async () => {},
217
+ onError = async () => {},
218
+ _poll = pollSessionEvents,
219
+ _readCursor = readSyncCursor,
220
+ _writeCursor = writeSyncCursor,
221
+ _sleep = defaultSleep,
222
+ _nowMs = Date.now,
223
+ } = {}) {
224
+ const normalizedSessionId = normalizeString(sessionId);
225
+ const normalizedAgentId = normalizeComparableId(agentId) || "cli-user";
226
+ if (!normalizedSessionId) {
227
+ throw new Error("session id is required.");
228
+ }
229
+
230
+ const cursorSuffix = listenCursorSuffix(normalizedAgentId);
231
+ let cursor =
232
+ typeof since === "string" || since === null
233
+ ? normalizeString(since) || null
234
+ : await _readCursor(normalizedSessionId, { targetPath, suffix: cursorSuffix });
235
+ let primed = Boolean(cursor) || Boolean(replay);
236
+ let pollCount = 0;
237
+ let emitted = 0;
238
+ let matched = 0;
239
+ let persistedCursor = false;
240
+ let lastReason = "";
241
+ const maxPollCount = normalizePositiveInteger(maxPolls, 0);
242
+ const pollLimit = normalizePositiveInteger(limit, 200);
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;
250
+ const startedAtMs = Number(_nowMs()) || Date.now();
251
+ let lastHumanActivityMs = 0;
252
+ let lastSleepMs = 0;
253
+
254
+ while (!signal?.aborted) {
255
+ pollCount += 1;
256
+ const result = await _poll(normalizedSessionId, {
257
+ targetPath,
258
+ since: cursor,
259
+ limit: pollLimit,
260
+ });
261
+
262
+ if (result?.ok) {
263
+ lastReason = "";
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
+ }
272
+ const shouldEmitBatch = primed || Boolean(replay);
273
+ for (const event of events) {
274
+ if (!eventMatchesAgent(event, normalizedAgentId)) continue;
275
+ matched += 1;
276
+ if (!shouldEmitBatch && eventTimestampMs(event) < startedAtMs) continue;
277
+ await onEvent(event);
278
+ emitted += 1;
279
+ }
280
+
281
+ const nextCursor = normalizeString(result.cursor) || cursorFromEvents(events, cursor);
282
+ if (nextCursor && nextCursor !== cursor) {
283
+ const writeResult = await _writeCursor(normalizedSessionId, nextCursor, {
284
+ targetPath,
285
+ suffix: cursorSuffix,
286
+ }).catch(() => null);
287
+ persistedCursor = Boolean(writeResult?.written) || persistedCursor;
288
+ cursor = nextCursor;
289
+ }
290
+ primed = true;
291
+ } else {
292
+ lastReason = normalizeString(result?.reason) || "poll_failed";
293
+ await onError({
294
+ ok: false,
295
+ reason: lastReason,
296
+ cursor: result?.cursor || cursor || null,
297
+ });
298
+ }
299
+
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;
305
+ try {
306
+ await _sleep(nextSleepMs, { signal });
307
+ } catch (error) {
308
+ if (shouldAbort(error, signal)) break;
309
+ throw error;
310
+ }
311
+ }
312
+
313
+ return {
314
+ ok: true,
315
+ sessionId: normalizedSessionId,
316
+ agentId: normalizedAgentId,
317
+ cursor,
318
+ cursorSuffix,
319
+ pollCount,
320
+ matched,
321
+ emitted,
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,
328
+ reason: lastReason,
329
+ };
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
+ }