sentinelayer-cli 0.17.0 → 0.17.1

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.
@@ -17,6 +17,19 @@ const DEFAULT_RECAP_INTERVAL_MS = 300_000;
17
17
  const DEFAULT_RECAP_INACTIVITY_MS = 600_000;
18
18
  const DEFAULT_RECAP_ACTIVITY_THRESHOLD = 5;
19
19
  const DEFAULT_TASK_SUMMARY_LIMIT = 3;
20
+ const RECAP_SOURCE_IGNORED_EVENTS = new Set([
21
+ "agent_heartbeat",
22
+ "agent_join",
23
+ "agent_status",
24
+ "context_briefing",
25
+ "daemon_alert",
26
+ "session_ack",
27
+ "session_checkpoint",
28
+ "session_reaction",
29
+ "session_recap",
30
+ "session_usage",
31
+ "session_view",
32
+ ]);
20
33
  const ACTIVE_TASK_STATUSES = new Set(["PENDING", "ACCEPTED", "BLOCKED"]);
21
34
  const TASK_STATUS_KEYS = {
22
35
  PENDING: "pending",
@@ -64,6 +77,184 @@ function isRecapEvent(event = {}) {
64
77
  return payload.ephemeral === true && normalizeString(payload.style) === RECAP_STYLE;
65
78
  }
66
79
 
80
+ function eventSequenceId(event = {}) {
81
+ const parsed = Number(event.sequenceId ?? event.sequence_id);
82
+ if (!Number.isFinite(parsed) || parsed <= 0) {
83
+ return null;
84
+ }
85
+ return Math.floor(parsed);
86
+ }
87
+
88
+ function eventCursor(event = {}) {
89
+ return normalizeString(event.cursor || event.eventId || event.idempotencyToken || event.ts);
90
+ }
91
+
92
+ function isMeaningfulRecapSourceEvent(event = {}) {
93
+ if (isRecapEvent(event)) {
94
+ return false;
95
+ }
96
+ const eventName = normalizeString(event.event).toLowerCase();
97
+ if (!eventName || RECAP_SOURCE_IGNORED_EVENTS.has(eventName)) {
98
+ return false;
99
+ }
100
+ return true;
101
+ }
102
+
103
+ function isEventAfterRecapAnchor(event = {}, state = {}) {
104
+ const anchorSequence = Number(state.lastSourceSequenceId);
105
+ const sequence = eventSequenceId(event);
106
+ if (Number.isFinite(anchorSequence) && anchorSequence > 0 && sequence !== null) {
107
+ return sequence > anchorSequence;
108
+ }
109
+
110
+ const anchorAt = normalizeString(state.lastSourceEventAt);
111
+ if (!anchorAt) {
112
+ return true;
113
+ }
114
+ const anchorEpoch = toEpoch(anchorAt, anchorAt);
115
+ const eventEpoch = toEpoch(event.ts, anchorAt);
116
+ if (eventEpoch > anchorEpoch) {
117
+ return true;
118
+ }
119
+ if (eventEpoch < anchorEpoch) {
120
+ return false;
121
+ }
122
+
123
+ const anchorCursor = normalizeString(state.lastSourceCursor);
124
+ if (!anchorCursor) {
125
+ return true;
126
+ }
127
+ return eventCursor(event) !== anchorCursor;
128
+ }
129
+
130
+ function rememberRecapSource(state, event = {}, nowIso = new Date().toISOString()) {
131
+ state.lastSourceEventAt = normalizeIsoTimestamp(event.ts, nowIso);
132
+ state.lastSourceSequenceId = eventSequenceId(event);
133
+ state.lastSourceCursor = eventCursor(event);
134
+ }
135
+
136
+ function resolvePeriodicRecapTrigger(state, events = [], nowIso = new Date().toISOString()) {
137
+ const sortedEvents = sortEventsByConversationTime(events, nowIso);
138
+ const sourceEvents = sortedEvents.filter(isMeaningfulRecapSourceEvent);
139
+ const unrecappedSourceEvents = sourceEvents.filter((event) => isEventAfterRecapAnchor(event, state));
140
+ const latestSourceEvent = sourceEvents.length > 0 ? sourceEvents[sourceEvents.length - 1] : null;
141
+ const latestUnrecappedSourceEvent =
142
+ unrecappedSourceEvents.length > 0
143
+ ? unrecappedSourceEvents[unrecappedSourceEvents.length - 1]
144
+ : null;
145
+ const nowEpoch = toEpoch(nowIso, nowIso);
146
+ const latestSourceEventAt = latestSourceEvent
147
+ ? normalizeIsoTimestamp(latestSourceEvent.ts, nowIso)
148
+ : null;
149
+ const latestUnrecappedSourceEventAt = latestUnrecappedSourceEvent
150
+ ? normalizeIsoTimestamp(latestUnrecappedSourceEvent.ts, nowIso)
151
+ : null;
152
+ const sourceIdleMs = latestSourceEvent
153
+ ? Math.max(0, nowEpoch - toEpoch(latestSourceEvent.ts, nowIso))
154
+ : null;
155
+ const unrecappedSourceIdleMs = latestUnrecappedSourceEvent
156
+ ? Math.max(0, nowEpoch - toEpoch(latestUnrecappedSourceEvent.ts, nowIso))
157
+ : null;
158
+ const sinceLastRecapMs = state.lastRecapAt
159
+ ? Math.max(0, nowEpoch - toEpoch(state.lastRecapAt, nowIso))
160
+ : null;
161
+ const policy = {
162
+ intervalMs: state.intervalMs,
163
+ inactivityMs: state.inactivityMs,
164
+ activityThreshold: state.newEventThreshold,
165
+ sourceEventCount: unrecappedSourceEvents.length,
166
+ totalSourceEventCount: sourceEvents.length,
167
+ latestSourceEventAt,
168
+ latestUnrecappedSourceEventAt,
169
+ sourceIdleMs,
170
+ unrecappedSourceIdleMs,
171
+ lastRecapAt: state.lastRecapAt,
172
+ lastSourceEventAt: state.lastSourceEventAt,
173
+ };
174
+
175
+ if (!latestSourceEvent) {
176
+ return {
177
+ shouldEmit: false,
178
+ shouldStop: false,
179
+ stopAfterEmit: false,
180
+ mode: "",
181
+ reason: "recap_no_source_events",
182
+ sourceEvent: null,
183
+ policy,
184
+ };
185
+ }
186
+
187
+ if (!latestUnrecappedSourceEvent) {
188
+ return {
189
+ shouldEmit: false,
190
+ shouldStop: sourceIdleMs !== null && sourceIdleMs >= state.inactivityMs,
191
+ stopAfterEmit: false,
192
+ mode: "",
193
+ reason: "recap_no_new_source_events",
194
+ sourceEvent: null,
195
+ policy,
196
+ };
197
+ }
198
+
199
+ if (unrecappedSourceIdleMs !== null && unrecappedSourceIdleMs >= state.inactivityMs) {
200
+ return {
201
+ shouldEmit: true,
202
+ shouldStop: false,
203
+ stopAfterEmit: true,
204
+ mode: "inactivity",
205
+ reason: "",
206
+ sourceEvent: latestUnrecappedSourceEvent,
207
+ policy,
208
+ };
209
+ }
210
+
211
+ if (!state.lastRecapAt) {
212
+ return {
213
+ shouldEmit: true,
214
+ shouldStop: false,
215
+ stopAfterEmit: false,
216
+ mode: "initial",
217
+ reason: "",
218
+ sourceEvent: latestUnrecappedSourceEvent,
219
+ policy,
220
+ };
221
+ }
222
+
223
+ if (unrecappedSourceEvents.length >= state.newEventThreshold) {
224
+ return {
225
+ shouldEmit: true,
226
+ shouldStop: false,
227
+ stopAfterEmit: false,
228
+ mode: "activity_threshold",
229
+ reason: "",
230
+ sourceEvent: latestUnrecappedSourceEvent,
231
+ policy,
232
+ };
233
+ }
234
+
235
+ if (sinceLastRecapMs !== null && sinceLastRecapMs >= state.intervalMs) {
236
+ return {
237
+ shouldEmit: true,
238
+ shouldStop: false,
239
+ stopAfterEmit: false,
240
+ mode: "periodic",
241
+ reason: "",
242
+ sourceEvent: latestUnrecappedSourceEvent,
243
+ policy,
244
+ };
245
+ }
246
+
247
+ return {
248
+ shouldEmit: false,
249
+ shouldStop: false,
250
+ stopAfterEmit: false,
251
+ mode: "",
252
+ reason: "recap_cadence_wait",
253
+ sourceEvent: latestUnrecappedSourceEvent,
254
+ policy,
255
+ };
256
+ }
257
+
67
258
  function parseFindingSeverity(text = "") {
68
259
  const normalized = normalizeString(text);
69
260
  if (!normalized) {
@@ -704,8 +895,8 @@ export async function shouldEmitRecap(
704
895
  tail: 0,
705
896
  since: normalizeString(lastReadAt) || null,
706
897
  });
707
- const relevantSinceRead = eventsSinceRead.filter((event) => {
708
- if (isRecapEvent(event)) {
898
+ const isRelevantSourceEvent = (event = {}) => {
899
+ if (!isMeaningfulRecapSourceEvent(event)) {
709
900
  return false;
710
901
  }
711
902
  const sourceAgent = normalizeString(event.agent?.id || event.agentId).toLowerCase();
@@ -713,20 +904,23 @@ export async function shouldEmitRecap(
713
904
  return true;
714
905
  }
715
906
  return sourceAgent !== normalizedAgentId;
716
- });
717
- if (relevantSinceRead.length > threshold) {
907
+ };
908
+ const relevantSinceRead = eventsSinceRead.filter(isRelevantSourceEvent);
909
+ if (relevantSinceRead.length >= threshold) {
718
910
  return true;
719
911
  }
720
912
 
721
913
  const latest = await readStream(normalizedSessionId, {
722
914
  targetPath,
723
- tail: 1,
915
+ tail: 200,
724
916
  });
725
- const latestEvent = latest.length > 0 ? latest[latest.length - 1] : null;
726
- if (!latestEvent || isRecapEvent(latestEvent)) {
917
+ const latestRelevant = sortEventsByConversationTime(latest, normalizedNow)
918
+ .filter(isRelevantSourceEvent)
919
+ .at(-1);
920
+ if (!latestRelevant) {
727
921
  return false;
728
922
  }
729
- const idleMs = Math.max(0, toEpoch(normalizedNow, normalizedNow) - toEpoch(latestEvent.ts, normalizedNow));
923
+ const idleMs = Math.max(0, toEpoch(normalizedNow, normalizedNow) - toEpoch(latestRelevant.ts, normalizedNow));
730
924
  return idleMs >= normalizedInactivityMs;
731
925
  }
732
926
 
@@ -735,6 +929,7 @@ export function emitPeriodicRecap(
735
929
  {
736
930
  intervalMs = DEFAULT_RECAP_INTERVAL_MS,
737
931
  inactivityMs = DEFAULT_RECAP_INACTIVITY_MS,
932
+ newEventThreshold = DEFAULT_RECAP_ACTIVITY_THRESHOLD,
738
933
  maxEvents = DEFAULT_RECAP_MAX_EVENTS,
739
934
  targetPath = process.cwd(),
740
935
  nowProvider = () => new Date().toISOString(),
@@ -751,6 +946,10 @@ export function emitPeriodicRecap(
751
946
  inactivityMs,
752
947
  DEFAULT_RECAP_INACTIVITY_MS
753
948
  );
949
+ const normalizedNewEventThreshold = normalizePositiveInteger(
950
+ newEventThreshold,
951
+ DEFAULT_RECAP_ACTIVITY_THRESHOLD
952
+ );
754
953
  const normalizedMaxEvents = normalizePositiveInteger(maxEvents, DEFAULT_RECAP_MAX_EVENTS);
755
954
  const key = buildRecapKey(normalizedSessionId, normalizedTargetPath);
756
955
  const existing = ACTIVE_RECAP_EMITTERS.get(key);
@@ -764,13 +963,18 @@ export function emitPeriodicRecap(
764
963
  startedAt: normalizeIsoTimestamp(nowProvider(), new Date().toISOString()),
765
964
  intervalMs: normalizedIntervalMs,
766
965
  inactivityMs: normalizedInactivityMs,
966
+ newEventThreshold: normalizedNewEventThreshold,
767
967
  maxEvents: normalizedMaxEvents,
768
968
  targetPath: normalizedTargetPath,
769
969
  sessionId: normalizedSessionId,
770
970
  timer: null,
971
+ inFlight: false,
771
972
  lastRecapAt: null,
772
973
  lastSourceEventAt: null,
974
+ lastSourceSequenceId: null,
975
+ lastSourceCursor: null,
773
976
  lastRecapEvent: null,
977
+ lastDecision: null,
774
978
  stoppedReason: null,
775
979
  };
776
980
 
@@ -794,77 +998,87 @@ export function emitPeriodicRecap(
794
998
  sessionId: state.sessionId,
795
999
  reason: state.stoppedReason,
796
1000
  lastRecapAt: state.lastRecapAt,
1001
+ lastDecision: state.lastDecision,
797
1002
  };
798
1003
  };
799
1004
 
800
- const tickNow = async () => {
801
- if (!state.running) {
802
- return null;
803
- }
804
- const nowIso = normalizeIsoTimestamp(nowProvider(), new Date().toISOString());
805
- const nowEpoch = toEpoch(nowIso, nowIso);
806
- const events = await readStream(state.sessionId, {
807
- targetPath: state.targetPath,
808
- tail: state.maxEvents,
809
- });
810
- const nonRecapEvents = events.filter((event) => !isRecapEvent(event));
811
- const latestSourceEvent = nonRecapEvents.length > 0 ? nonRecapEvents[nonRecapEvents.length - 1] : null;
812
- if (!latestSourceEvent) {
813
- return null;
814
- }
815
-
816
- const latestSourceEpoch = toEpoch(latestSourceEvent.ts, nowIso);
817
- const idleMs = Math.max(0, nowEpoch - latestSourceEpoch);
818
- if (idleMs >= state.inactivityMs) {
819
- stop("inactive");
1005
+ const tickNow = async (overrideNowIso = "") => {
1006
+ if (!state.running || state.inFlight) {
820
1007
  return null;
821
1008
  }
822
-
823
- if (state.lastRecapAt) {
824
- const sinceLastRecapMs = Math.max(0, nowEpoch - toEpoch(state.lastRecapAt, nowIso));
825
- if (sinceLastRecapMs < state.intervalMs) {
826
- return null;
827
- }
828
- }
829
- if (state.lastSourceEventAt) {
830
- const previousSourceEpoch = toEpoch(state.lastSourceEventAt, nowIso);
831
- if (latestSourceEpoch <= previousSourceEpoch) {
1009
+ state.inFlight = true;
1010
+ try {
1011
+ const nowIso = normalizeIsoTimestamp(
1012
+ overrideNowIso || nowProvider(),
1013
+ new Date().toISOString()
1014
+ );
1015
+ const events = await readStream(state.sessionId, {
1016
+ targetPath: state.targetPath,
1017
+ tail: state.maxEvents,
1018
+ });
1019
+ const trigger = resolvePeriodicRecapTrigger(state, events, nowIso);
1020
+ state.lastDecision = {
1021
+ emitted: false,
1022
+ mode: trigger.mode,
1023
+ reason: trigger.reason,
1024
+ policy: trigger.policy,
1025
+ };
1026
+ if (!trigger.shouldEmit) {
1027
+ if (trigger.shouldStop) {
1028
+ stop("inactive");
1029
+ }
832
1030
  return null;
833
1031
  }
834
- }
835
1032
 
836
- const recap = await buildSessionRecap(state.sessionId, {
837
- targetPath: state.targetPath,
838
- maxEvents: state.maxEvents,
839
- nowIso,
840
- });
841
- const text = buildPeriodicText(recap);
842
- const event = createAgentEvent({
843
- event: "session_recap",
844
- agentId: SENTI_AGENT_ID,
845
- agentModel: SENTI_MODEL,
846
- sessionId: state.sessionId,
847
- ts: nowIso,
848
- payload: {
849
- mode: "periodic",
850
- recap: text,
851
- ephemeral: true,
852
- style: RECAP_STYLE,
853
- generatedAt: nowIso,
854
- summary: recap.summary,
855
- },
856
- });
857
- const persisted = await appendToStream(state.sessionId, event, {
858
- targetPath: state.targetPath,
859
- });
860
- state.lastRecapAt = nowIso;
861
- state.lastSourceEventAt = normalizeIsoTimestamp(latestSourceEvent.ts, nowIso);
862
- state.lastRecapEvent = persisted;
1033
+ const recap = await buildSessionRecap(state.sessionId, {
1034
+ targetPath: state.targetPath,
1035
+ maxEvents: state.maxEvents,
1036
+ nowIso,
1037
+ });
1038
+ const text = buildPeriodicText(recap);
1039
+ const event = createAgentEvent({
1040
+ event: "session_recap",
1041
+ agentId: SENTI_AGENT_ID,
1042
+ agentModel: SENTI_MODEL,
1043
+ sessionId: state.sessionId,
1044
+ ts: nowIso,
1045
+ payload: {
1046
+ mode: trigger.mode,
1047
+ recap: text,
1048
+ ephemeral: true,
1049
+ style: RECAP_STYLE,
1050
+ generatedAt: nowIso,
1051
+ sourceEventCount: trigger.policy.sourceEventCount,
1052
+ latestSourceEventAt: trigger.policy.latestUnrecappedSourceEventAt,
1053
+ policy: trigger.policy,
1054
+ summary: recap.summary,
1055
+ },
1056
+ });
1057
+ const persisted = await appendToStream(state.sessionId, event, {
1058
+ targetPath: state.targetPath,
1059
+ });
1060
+ state.lastRecapAt = nowIso;
1061
+ rememberRecapSource(state, trigger.sourceEvent, nowIso);
1062
+ state.lastRecapEvent = persisted;
1063
+ state.lastDecision = {
1064
+ emitted: true,
1065
+ mode: trigger.mode,
1066
+ reason: "",
1067
+ eventId: persisted.eventId || null,
1068
+ sourceEventCount: trigger.policy.sourceEventCount,
1069
+ policy: trigger.policy,
1070
+ };
863
1071
 
864
- if (typeof onEmit === "function") {
865
- await onEmit(persisted, recap);
1072
+ if (typeof onEmit === "function") {
1073
+ await onEmit(persisted, recap);
1074
+ }
1075
+ if (trigger.stopAfterEmit) {
1076
+ stop("inactive");
1077
+ }
1078
+ return persisted;
1079
+ } finally {
1080
+ state.inFlight = false;
866
1081
  }
867
- return persisted;
868
1082
  };
869
1083
 
870
1084
  const handle = {
@@ -880,8 +1094,12 @@ export function emitPeriodicRecap(
880
1094
  running: state.running,
881
1095
  intervalMs: state.intervalMs,
882
1096
  inactivityMs: state.inactivityMs,
1097
+ newEventThreshold: state.newEventThreshold,
1098
+ inFlight: state.inFlight,
883
1099
  lastRecapAt: state.lastRecapAt,
884
1100
  lastSourceEventAt: state.lastSourceEventAt,
1101
+ lastSourceSequenceId: state.lastSourceSequenceId,
1102
+ lastDecision: state.lastDecision,
885
1103
  stoppedReason: state.stoppedReason,
886
1104
  }),
887
1105
  };
@@ -906,6 +1124,7 @@ export function getPeriodicRecapEmitter(sessionId, { targetPath = process.cwd()
906
1124
 
907
1125
  export {
908
1126
  ACTIVE_RECAP_EMITTERS,
1127
+ DEFAULT_RECAP_ACTIVITY_THRESHOLD,
909
1128
  DEFAULT_RECAP_INACTIVITY_MS,
910
1129
  DEFAULT_RECAP_INTERVAL_MS,
911
1130
  DEFAULT_RECAP_MAX_EVENTS,
@@ -1901,10 +1901,7 @@ export async function probeSessionAccess(
1901
1901
  }
1902
1902
 
1903
1903
  export function resetSessionSyncStateForTests() {
1904
- outboundCircuit.consecutiveFailures = 0;
1905
- outboundCircuit.openedAtMs = 0;
1906
- inboundCircuit.consecutiveFailures = 0;
1907
- inboundCircuit.openedAtMs = 0;
1904
+ __resetCircuitStateForTests();
1908
1905
  sessionIngestWindowBySessionId.clear();
1909
1906
  humanRelayWindowBySessionId.clear();
1910
1907
  autoGrantAttemptedAgentIds.clear();