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/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@55a2c158f637d7d92e26ab0ef3ba81db791da4be
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' }}
@@ -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@55a2c158f637d7d92e26ab0ef3ba81db791da4be";
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`; 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
+ "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
+ }
@@ -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 sleepMs = Math.max(1, normalizePositiveInteger(intervalSeconds, 60)) * 1000;
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(sleepMs, { signal });
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
+ }