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.
@@ -18,11 +18,159 @@
18
18
  * sync, and vice-versa.
19
19
  */
20
20
 
21
- import { pollHumanMessages, pollSessionEvents } from "./sync.js";
22
- import { appendToStream } from "./stream.js";
21
+ import { listSessionsFromApi, pollHumanMessages, pollSessionEvents } from "./sync.js";
22
+ import { appendToStream, readStream } from "./stream.js";
23
+ import { createSession, getSession } from "./store.js";
23
24
  import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
25
+ import {
26
+ addSessionEventIdentityKeys,
27
+ sessionEventHasKnownIdentity,
28
+ } from "./event-identity.js";
24
29
 
25
30
  const EVENTS_CURSOR_SUFFIX = "events";
31
+ const DEFAULT_EVENT_PAGE_LIMIT = 200;
32
+ const DEFAULT_MAX_EVENT_PAGES = 25;
33
+
34
+ async function readExistingRelayKeys(sessionId, { targetPath = process.cwd() } = {}) {
35
+ const knownKeys = new Set();
36
+ const events = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
37
+ for (const event of events) {
38
+ addSessionEventIdentityKeys(knownKeys, event);
39
+ }
40
+ return knownKeys;
41
+ }
42
+
43
+ async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() } = {}) {
44
+ const existing = await getSession(sessionId, { targetPath });
45
+ if (existing) {
46
+ return { materialized: false, session: existing };
47
+ }
48
+ let remoteStatus = "";
49
+ const remoteList = await listSessionsFromApi({
50
+ targetPath,
51
+ includeArchived: true,
52
+ limit: 200,
53
+ }).catch(() => null);
54
+ if (remoteList?.ok) {
55
+ const match = (remoteList.sessions || []).find((entry) => entry?.sessionId === sessionId);
56
+ remoteStatus = String(match?.archiveStatus || match?.status || "").trim().toLowerCase();
57
+ }
58
+ const created = await createSession({
59
+ targetPath,
60
+ sessionId,
61
+ title: `remote-${String(sessionId).slice(0, 8)}`,
62
+ });
63
+ return { materialized: true, session: created, remoteStatus };
64
+ }
65
+
66
+ function sourceFullyRelayed(events = [], successfulKeys = new Set()) {
67
+ const relayedEvents = Array.isArray(events) ? events : [];
68
+ if (relayedEvents.length === 0) return true;
69
+ return relayedEvents.every((event) => sessionEventHasKnownIdentity(event, successfulKeys));
70
+ }
71
+
72
+ function markPostKillEvent(event = {}) {
73
+ if (!event || typeof event !== "object" || Array.isArray(event)) return event;
74
+ const payload = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
75
+ ? event.payload
76
+ : {};
77
+ return {
78
+ ...event,
79
+ _post_kill: true,
80
+ payload: {
81
+ ...payload,
82
+ _post_kill: true,
83
+ },
84
+ };
85
+ }
86
+
87
+ function normalizePositiveInteger(value, fallbackValue) {
88
+ if (value === undefined || value === null || String(value).trim() === "") {
89
+ return fallbackValue;
90
+ }
91
+ const normalized = Number(value);
92
+ if (!Number.isFinite(normalized) || normalized <= 0) {
93
+ return fallbackValue;
94
+ }
95
+ return Math.floor(normalized);
96
+ }
97
+
98
+ async function pollSessionEventPages({
99
+ sessionId,
100
+ targetPath,
101
+ since,
102
+ _pollEvents,
103
+ limit,
104
+ maxPages,
105
+ forceCircuitProbe = false,
106
+ }) {
107
+ const normalizedLimit = Math.max(
108
+ 1,
109
+ Math.min(DEFAULT_EVENT_PAGE_LIMIT, normalizePositiveInteger(limit, DEFAULT_EVENT_PAGE_LIMIT)),
110
+ );
111
+ const normalizedMaxPages = Math.max(
112
+ 1,
113
+ Math.min(100, normalizePositiveInteger(maxPages, DEFAULT_MAX_EVENT_PAGES)),
114
+ );
115
+ const events = [];
116
+ let cursor = typeof since === "string" && since.trim() ? since.trim() : null;
117
+ let reason = "";
118
+ let pageCount = 0;
119
+
120
+ for (let page = 0; page < normalizedMaxPages; page += 1) {
121
+ const result = await _pollEvents(sessionId, {
122
+ targetPath,
123
+ since: cursor,
124
+ limit: normalizedLimit,
125
+ forceCircuitProbe,
126
+ });
127
+ pageCount += 1;
128
+ if (!result?.ok) {
129
+ return {
130
+ ok: events.length > 0,
131
+ reason: result?.reason || "poll_failed",
132
+ events,
133
+ cursor,
134
+ pageCount,
135
+ complete: false,
136
+ truncated: events.length > 0,
137
+ };
138
+ }
139
+
140
+ const pageEvents = Array.isArray(result.events) ? result.events : [];
141
+ events.push(...pageEvents);
142
+ const nextCursor =
143
+ typeof result.cursor === "string" && result.cursor.trim() ? result.cursor.trim() : cursor;
144
+ const progressed = nextCursor && nextCursor !== cursor;
145
+ cursor = nextCursor || cursor;
146
+
147
+ if (pageEvents.length < normalizedLimit) {
148
+ return {
149
+ ok: true,
150
+ reason: "",
151
+ events,
152
+ cursor,
153
+ pageCount,
154
+ complete: true,
155
+ truncated: false,
156
+ };
157
+ }
158
+ if (!progressed) {
159
+ reason = "cursor_not_advanced";
160
+ break;
161
+ }
162
+ }
163
+
164
+ return {
165
+ ok: events.length > 0,
166
+ reason: reason || "max_event_pages_reached",
167
+ events,
168
+ cursor,
169
+ pageCount,
170
+ complete: false,
171
+ truncated: true,
172
+ };
173
+ }
26
174
 
27
175
  /**
28
176
  * Fetch new human messages for a session, append them to the local
@@ -46,6 +194,10 @@ export async function hydrateSessionFromRemote({
46
194
  _poll = pollHumanMessages,
47
195
  _pollEvents = pollSessionEvents,
48
196
  _append = appendToStream,
197
+ _ensureLocalSession = ensureLocalSessionShell,
198
+ probeOpenCircuit = true,
199
+ eventPageLimit = DEFAULT_EVENT_PAGE_LIMIT,
200
+ maxEventPages = DEFAULT_MAX_EVENT_PAGES,
49
201
  } = {}) {
50
202
  if (!sessionId || typeof sessionId !== "string") {
51
203
  return {
@@ -74,25 +226,56 @@ export async function hydrateSessionFromRemote({
74
226
  // Run both pollers in parallel — they hit different endpoints and
75
227
  // are independent. A human-only poll stays fast even when the
76
228
  // events poll is heavy.
77
- const [humanResult, eventsResult] = await Promise.all([
229
+ let [humanResult, eventsResult] = await Promise.all([
78
230
  _poll(sessionId, { targetPath, since: humanCursor }),
79
- _pollEvents(sessionId, { targetPath, since: eventsCursor }),
231
+ pollSessionEventPages({
232
+ sessionId,
233
+ targetPath,
234
+ since: eventsCursor,
235
+ _pollEvents,
236
+ limit: eventPageLimit,
237
+ maxPages: maxEventPages,
238
+ }),
80
239
  ]);
81
240
 
241
+ if (
242
+ probeOpenCircuit &&
243
+ humanResult?.reason === "circuit_breaker_open" &&
244
+ eventsResult?.reason === "circuit_breaker_open"
245
+ ) {
246
+ [humanResult, eventsResult] = await Promise.all([
247
+ _poll(sessionId, { targetPath, since: humanCursor, forceCircuitProbe: true }),
248
+ pollSessionEventPages({
249
+ sessionId,
250
+ targetPath,
251
+ since: eventsCursor,
252
+ _pollEvents,
253
+ limit: eventPageLimit,
254
+ maxPages: maxEventPages,
255
+ forceCircuitProbe: true,
256
+ }),
257
+ ]);
258
+ }
259
+
82
260
  // Dedup across sources — both endpoints can return the same event
83
261
  // (e.g. a human relay event). Cursor values are unique per event.
84
262
  const seenCursors = new Set();
263
+ const seenKeys = new Set();
85
264
  const merged = [];
86
265
  for (const e of humanResult?.events || []) {
87
266
  const c = (e && typeof e === "object" && typeof e.cursor === "string") ? e.cursor : "";
88
267
  if (c && seenCursors.has(c)) continue;
268
+ if (sessionEventHasKnownIdentity(e, seenKeys)) continue;
89
269
  if (c) seenCursors.add(c);
270
+ addSessionEventIdentityKeys(seenKeys, e);
90
271
  merged.push(e);
91
272
  }
92
273
  for (const e of eventsResult?.events || []) {
93
274
  const c = (e && typeof e === "object" && typeof e.cursor === "string") ? e.cursor : "";
94
275
  if (c && seenCursors.has(c)) continue;
276
+ if (sessionEventHasKnownIdentity(e, seenKeys)) continue;
95
277
  if (c) seenCursors.add(c);
278
+ addSessionEventIdentityKeys(seenKeys, e);
96
279
  merged.push(e);
97
280
  }
98
281
 
@@ -112,10 +295,33 @@ export async function hydrateSessionFromRemote({
112
295
  }
113
296
 
114
297
  let relayed = 0;
115
- for (const event of merged) {
298
+ let materializedLocalSession = false;
299
+ let remoteStatus = "";
300
+ const successfulRelayKeys =
301
+ merged.length > 0 ? await readExistingRelayKeys(sessionId, { targetPath }) : new Set();
302
+ const newEvents = successfulRelayKeys.size > 0
303
+ ? merged.filter((event) => !sessionEventHasKnownIdentity(event, successfulRelayKeys))
304
+ : merged;
305
+ if (newEvents.length > 0) {
306
+ try {
307
+ const localSession = await _ensureLocalSession(sessionId, { targetPath });
308
+ materializedLocalSession = Boolean(localSession?.materialized);
309
+ remoteStatus = String(localSession?.remoteStatus || "").trim().toLowerCase();
310
+ } catch {
311
+ // Keep the old degraded behavior: append attempts below will
312
+ // fail visibly in the returned counters, but remote polling still
313
+ // returns a structured result.
314
+ }
315
+ }
316
+ const appendEvents =
317
+ remoteStatus && !["active", "pending"].includes(remoteStatus)
318
+ ? newEvents.map((event) => markPostKillEvent(event))
319
+ : newEvents;
320
+ for (const event of appendEvents) {
116
321
  try {
117
- await _append(sessionId, event, { targetPath });
322
+ await _append(sessionId, event, { targetPath, syncRemote: false });
118
323
  relayed += 1;
324
+ addSessionEventIdentityKeys(successfulRelayKeys, event);
119
325
  } catch {
120
326
  // Append errors are observable via the stream but should not
121
327
  // abort the rest of the batch — partial relay is still progress.
@@ -123,11 +329,13 @@ export async function hydrateSessionFromRemote({
123
329
  }
124
330
 
125
331
  let persistedCursor = false;
126
- if (typeof humanResult?.cursor === "string" && humanResult.cursor.trim()) {
332
+ const humanCursorSafe = sourceFullyRelayed(humanResult?.events || [], successfulRelayKeys);
333
+ const eventsCursorSafe = sourceFullyRelayed(eventsResult?.events || [], successfulRelayKeys);
334
+ if (humanCursorSafe && typeof humanResult?.cursor === "string" && humanResult.cursor.trim()) {
127
335
  const result = await writeSyncCursor(sessionId, humanResult.cursor, { targetPath }).catch(() => null);
128
336
  persistedCursor = Boolean(result && result.written);
129
337
  }
130
- if (typeof eventsResult?.cursor === "string" && eventsResult.cursor.trim()) {
338
+ if (eventsCursorSafe && typeof eventsResult?.cursor === "string" && eventsResult.cursor.trim()) {
131
339
  await writeSyncCursor(sessionId, eventsResult.cursor, {
132
340
  targetPath,
133
341
  suffix: EVENTS_CURSOR_SUFFIX,
@@ -145,5 +353,12 @@ export async function hydrateSessionFromRemote({
145
353
  eventsRelayed: (eventsResult?.events || []).length,
146
354
  eventsCursor:
147
355
  typeof eventsResult?.cursor === "string" ? eventsResult.cursor : eventsCursor || null,
356
+ eventsPageCount: Number(eventsResult?.pageCount || 0),
357
+ eventsBackfillComplete: Boolean(eventsResult?.complete !== false),
358
+ eventsBackfillTruncated: Boolean(eventsResult?.truncated),
359
+ eventsBackfillReason: eventsResult?.complete === false ? eventsResult?.reason || "" : "",
360
+ materializedLocalSession,
361
+ localAppendComplete: humanCursorSafe && eventsCursorSafe,
362
+ remoteStatus: remoteStatus || null,
148
363
  };
149
364
  }
@@ -183,6 +183,31 @@ function normalizeSharedResources(raw = {}, { nowIso = new Date().toISOString()
183
183
  };
184
184
  }
185
185
 
186
+ function normalizeRemoteTitleSync(raw = {}, { nowIso = new Date().toISOString() } = {}) {
187
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
188
+ return null;
189
+ }
190
+ const title = normalizeString(raw.title);
191
+ const lastAttemptAt = raw.lastAttemptAt
192
+ ? normalizeIsoTimestamp(raw.lastAttemptAt, nowIso)
193
+ : null;
194
+ const lastSyncedAt = raw.lastSyncedAt
195
+ ? normalizeIsoTimestamp(raw.lastSyncedAt, nowIso)
196
+ : null;
197
+ const failureReason = normalizeString(raw.failureReason) || null;
198
+ const pending = Boolean(raw.pending);
199
+ if (!pending && !title && !lastAttemptAt && !lastSyncedAt && !failureReason) {
200
+ return null;
201
+ }
202
+ return {
203
+ pending,
204
+ title: title || null,
205
+ lastAttemptAt,
206
+ lastSyncedAt,
207
+ failureReason,
208
+ };
209
+ }
210
+
186
211
  function normalizeTemplateAgent(raw = {}) {
187
212
  const source = raw && typeof raw === "object" ? raw : {};
188
213
  return {
@@ -357,6 +382,7 @@ function normalizeMetadata(raw = {}, { sessionId, targetPath, nowIso } = {}) {
357
382
  archiveStatus: normalizeString(raw.archiveStatus) || "pending",
358
383
  codebaseContext: normalizeCodebaseContext(raw.codebaseContext || {}),
359
384
  sharedResources: normalizeSharedResources(raw.sharedResources || {}, { nowIso }),
385
+ remoteTitleSync: normalizeRemoteTitleSync(raw.remoteTitleSync || null, { nowIso }),
360
386
  template: normalizeSessionTemplate(raw.template || null),
361
387
  };
362
388
  }
@@ -391,6 +417,7 @@ function buildSessionPayload(metadata, paths, nowIso = new Date().toISOString())
391
417
  s3Path: metadata.s3Path,
392
418
  codebaseContext: metadata.codebaseContext,
393
419
  sharedResources: metadata.sharedResources,
420
+ remoteTitleSync: metadata.remoteTitleSync,
394
421
  template: metadata.template,
395
422
  };
396
423
  }
@@ -464,6 +491,7 @@ export async function createSession({
464
491
  archiveStatus: "pending",
465
492
  codebaseContext,
466
493
  sharedResources: normalizeSharedResources({}, { nowIso }),
494
+ remoteTitleSync: null,
467
495
  template: normalizeSessionTemplate(template),
468
496
  },
469
497
  {
@@ -498,6 +526,41 @@ export async function updateSessionTitle(
498
526
  return buildSessionPayload(saved, loaded.paths, nowIso);
499
527
  }
500
528
 
529
+ export async function recordSessionRemoteTitleSync(
530
+ sessionId,
531
+ {
532
+ targetPath = process.cwd(),
533
+ title = "",
534
+ pending = false,
535
+ failureReason = "",
536
+ lastAttemptAt = "",
537
+ lastSyncedAt = "",
538
+ } = {}
539
+ ) {
540
+ const loaded = await loadMetadata(sessionId, { targetPath });
541
+ if (!loaded) {
542
+ return null;
543
+ }
544
+ const nowIso = new Date().toISOString();
545
+ const metadata = {
546
+ ...loaded.metadata,
547
+ remoteTitleSync: normalizeRemoteTitleSync(
548
+ {
549
+ pending: Boolean(pending),
550
+ title: normalizeString(title) || normalizeString(loaded.metadata.title) || null,
551
+ lastAttemptAt: lastAttemptAt || nowIso,
552
+ lastSyncedAt:
553
+ !pending && !normalizeString(failureReason) ? lastSyncedAt || nowIso : lastSyncedAt || null,
554
+ failureReason: normalizeString(failureReason) || null,
555
+ },
556
+ { nowIso },
557
+ ),
558
+ updatedAt: nowIso,
559
+ };
560
+ const saved = await saveMetadata(metadata, loaded.paths);
561
+ return buildSessionPayload(saved, loaded.paths, nowIso);
562
+ }
563
+
501
564
  export async function getSession(sessionId, { targetPath = process.cwd() } = {}) {
502
565
  const loaded = await loadMetadata(sessionId, { targetPath });
503
566
  if (!loaded) {
@@ -3,6 +3,7 @@ import process from "node:process";
3
3
  import { setTimeout as sleep } from "node:timers/promises";
4
4
 
5
5
  import { createAgentEvent, normalizeAgentEvent } from "../events/schema.js";
6
+ import { enrichEventWithMentions } from "./mentions.js";
6
7
  import { resolveSessionPaths } from "./paths.js";
7
8
  import { redactEventPayload } from "./redact.js";
8
9
  import { syncSessionEventToApi } from "./sync.js";
@@ -223,7 +224,7 @@ function filterBySince(events = [], since) {
223
224
  export async function appendToStream(
224
225
  sessionId,
225
226
  event,
226
- { targetPath = process.cwd(), maxEvents = DEFAULT_MAX_STREAM_EVENTS } = {}
227
+ { targetPath = process.cwd(), maxEvents = DEFAULT_MAX_STREAM_EVENTS, syncRemote = true } = {}
227
228
  ) {
228
229
  const paths = resolveSessionPaths(sessionId, { targetPath });
229
230
  const metadata = await readSessionMetadata(paths);
@@ -235,7 +236,14 @@ export async function appendToStream(
235
236
  }
236
237
 
237
238
  const rawEvent = materializeCanonicalEvent(paths.sessionId, event);
238
- const canonicalEvent = redactEventPayload(rawEvent);
239
+ // Parse `@<name>` mentions out of the message text and surface them
240
+ // as payload.to + payload.mentions so the listener (sl session listen)
241
+ // and Senti daemon can route to/notify the addressed agent. Pure +
242
+ // idempotent — if the caller already supplied an explicit `to/recipient`
243
+ // we leave it alone. Runs before redaction so the redactor sees a
244
+ // stable shape.
245
+ const enrichedEvent = enrichEventWithMentions(rawEvent);
246
+ const canonicalEvent = redactEventPayload(enrichedEvent);
239
247
  const nowIso = new Date().toISOString();
240
248
  const normalizedMaxEvents = normalizePositiveInteger(maxEvents, DEFAULT_MAX_STREAM_EVENTS);
241
249
 
@@ -252,10 +260,12 @@ export async function appendToStream(
252
260
  await releaseLock(paths.lockPath);
253
261
  }
254
262
 
255
- // Best-effort dashboard sync. Never block local stream durability on API state.
256
- void syncSessionEventToApi(paths.sessionId, canonicalEvent, {
257
- targetPath,
258
- }).catch(() => {});
263
+ if (syncRemote) {
264
+ // Best-effort dashboard sync. Never block local stream durability on API state.
265
+ void syncSessionEventToApi(paths.sessionId, canonicalEvent, {
266
+ targetPath,
267
+ }).catch(() => {});
268
+ }
259
269
 
260
270
  return canonicalEvent;
261
271
  }
@@ -320,7 +330,7 @@ export async function* tailStream(
320
330
  }
321
331
 
322
332
  try {
323
- await sleep(normalizedPollMs, null, signal ? { signal } : undefined);
333
+ await sleep(normalizedPollMs, null, signal ? { signal, ref: false } : { ref: false });
324
334
  } catch (error) {
325
335
  if (error && typeof error === "object" && error.name === "AbortError") {
326
336
  return;