sentinelayer-cli 0.30.3 → 0.30.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.30.3",
3
+ "version": "0.30.4",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -76,6 +76,8 @@ import { appendToStream, readStream, tailStream } from "../session/stream.js";
76
76
  import {
77
77
  addSessionEventIdentityKeys,
78
78
  dedupeSessionEvents,
79
+ sessionEventIdentityKeys,
80
+ sessionEventUpgradesExisting,
79
81
  sessionEventHasKnownIdentity,
80
82
  } from "../session/event-identity.js";
81
83
  import { readSessionPreview } from "../session/preview.js";
@@ -2288,6 +2290,7 @@ async function hydrateJoinBriefingContext(sessionId, { targetPath, limit = 100 }
2288
2290
  reason: "",
2289
2291
  remoteEvents: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
2290
2292
  appended: appended.appended,
2293
+ upgraded: appended.upgraded,
2291
2294
  skipped: appended.skipped,
2292
2295
  failed: appended.failed,
2293
2296
  cursor: remoteTail.cursor || null,
@@ -2305,6 +2308,34 @@ async function hydrateJoinBriefingContext(sessionId, { targetPath, limit = 100 }
2305
2308
  }
2306
2309
  }
2307
2310
 
2311
+ function remoteSessionEventUpgradesLocal(localEvent = {}, remoteEvent = {}) {
2312
+ return sessionEventUpgradesExisting(localEvent, remoteEvent);
2313
+ }
2314
+
2315
+ function indexSessionEventIdentityKeys(indexByKey, eventList = [], event = {}, index = -1) {
2316
+ if (!indexByKey || !Array.isArray(eventList) || index < 0) return;
2317
+ for (const key of sessionEventIdentityKeys(event)) {
2318
+ const existingIndex = indexByKey.get(key);
2319
+ const existingEvent = Number.isInteger(existingIndex) && existingIndex >= 0
2320
+ ? eventList[existingIndex]
2321
+ : null;
2322
+ if (!existingEvent || remoteSessionEventUpgradesLocal(existingEvent, event)) {
2323
+ indexByKey.set(key, index);
2324
+ }
2325
+ }
2326
+ }
2327
+
2328
+ function findSessionEventIdentityIndex(indexByKey, event = {}) {
2329
+ if (!indexByKey) return -1;
2330
+ for (const key of sessionEventIdentityKeys(event)) {
2331
+ const index = indexByKey.get(key);
2332
+ if (Number.isInteger(index) && index >= 0) {
2333
+ return index;
2334
+ }
2335
+ }
2336
+ return -1;
2337
+ }
2338
+
2308
2339
  async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetPath } = {}) {
2309
2340
  const events = Array.isArray(remoteEvents) ? remoteEvents : [];
2310
2341
  if (events.length === 0) {
@@ -2315,18 +2346,47 @@ async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetP
2315
2346
  };
2316
2347
  }
2317
2348
  const knownKeys = new Set();
2349
+ const localIndexByKey = new Map();
2318
2350
  const localEvents = await readStream(sessionId, {
2319
2351
  targetPath,
2320
2352
  tail: 0,
2321
2353
  });
2322
- for (const event of localEvents) {
2354
+ for (const [index, event] of localEvents.entries()) {
2323
2355
  addSessionEventIdentityKeys(knownKeys, event);
2356
+ indexSessionEventIdentityKeys(localIndexByKey, localEvents, event, index);
2324
2357
  }
2325
2358
 
2326
2359
  let appended = 0;
2360
+ let upgraded = 0;
2327
2361
  let skipped = 0;
2328
2362
  let failed = 0;
2329
2363
  for (const event of events) {
2364
+ const existingIndex = findSessionEventIdentityIndex(localIndexByKey, event);
2365
+ if (existingIndex >= 0) {
2366
+ const existing = localEvents[existingIndex];
2367
+ if (!remoteSessionEventUpgradesLocal(existing, event)) {
2368
+ skipped += 1;
2369
+ continue;
2370
+ }
2371
+ // Keep the richer canonical API row in local history when it
2372
+ // upgrades an optimistic/local post that had no durable cursor yet.
2373
+ // Future reads can then page by sequence instead of rediscovering the
2374
+ // upgrade every time.
2375
+ try {
2376
+ const persisted = await appendToStream(sessionId, event, {
2377
+ targetPath,
2378
+ syncRemote: false,
2379
+ });
2380
+ localEvents.push(persisted);
2381
+ addSessionEventIdentityKeys(knownKeys, persisted);
2382
+ indexSessionEventIdentityKeys(localIndexByKey, localEvents, persisted, localEvents.length - 1);
2383
+ upgraded += 1;
2384
+ } catch {
2385
+ addSessionEventIdentityKeys(knownKeys, event);
2386
+ failed += 1;
2387
+ }
2388
+ continue;
2389
+ }
2330
2390
  if (sessionEventHasKnownIdentity(event, knownKeys)) {
2331
2391
  skipped += 1;
2332
2392
  continue;
@@ -2345,6 +2405,7 @@ async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetP
2345
2405
  }
2346
2406
  return {
2347
2407
  appended,
2408
+ upgraded,
2348
2409
  skipped,
2349
2410
  failed,
2350
2411
  };
@@ -4954,12 +5015,26 @@ export function registerSessionCommand(program) {
4954
5015
  const displayEvents = [...allEvents];
4955
5016
  let remoteTailAppended = 0;
4956
5017
  let remoteTailDisplayedOnly = 0;
5018
+ let remoteTailUpgraded = 0;
4957
5019
  if (remoteTail?.ok && Array.isArray(remoteTail.events) && remoteTail.events.length > 0) {
4958
5020
  const knownKeys = new Set();
4959
- for (const event of allEvents) {
5021
+ const displayIndexByKey = new Map();
5022
+ for (const [index, event] of displayEvents.entries()) {
4960
5023
  addSessionEventIdentityKeys(knownKeys, event);
5024
+ indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, index);
4961
5025
  }
4962
5026
  for (const event of remoteTail.events) {
5027
+ const existingIndex = findSessionEventIdentityIndex(displayIndexByKey, event);
5028
+ if (existingIndex >= 0) {
5029
+ const existing = displayEvents[existingIndex];
5030
+ if (remoteSessionEventUpgradesLocal(existing, event)) {
5031
+ displayEvents[existingIndex] = event;
5032
+ addSessionEventIdentityKeys(knownKeys, event);
5033
+ indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, existingIndex);
5034
+ remoteTailUpgraded += 1;
5035
+ }
5036
+ continue;
5037
+ }
4963
5038
  if (sessionEventHasKnownIdentity(event, knownKeys)) {
4964
5039
  continue;
4965
5040
  }
@@ -4970,10 +5045,12 @@ export function registerSessionCommand(program) {
4970
5045
  });
4971
5046
  displayEvents.push(appended);
4972
5047
  addSessionEventIdentityKeys(knownKeys, appended);
5048
+ indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, appended, displayEvents.length - 1);
4973
5049
  remoteTailAppended += 1;
4974
5050
  } catch {
4975
5051
  displayEvents.push(event);
4976
5052
  addSessionEventIdentityKeys(knownKeys, event);
5053
+ indexSessionEventIdentityKeys(displayIndexByKey, displayEvents, event, displayEvents.length - 1);
4977
5054
  remoteTailDisplayedOnly += 1;
4978
5055
  }
4979
5056
  }
@@ -4987,10 +5064,11 @@ export function registerSessionCommand(program) {
4987
5064
  const recentHumanActivity = remoteActions?.ok
4988
5065
  ? readProjectionRecentHumanActivity(remoteActions)
4989
5066
  : [];
5067
+ const dedupedDisplayEvents = dedupeSessionEvents(displayEvents);
4990
5068
  const transcriptEvents = includeControlEvents
4991
- ? displayEvents
4992
- : displayEvents.filter((event) => !isSessionControlEvent(event));
4993
- const hiddenControlEventCount = displayEvents.length - transcriptEvents.length;
5069
+ ? dedupedDisplayEvents
5070
+ : dedupedDisplayEvents.filter((event) => !isSessionControlEvent(event));
5071
+ const hiddenControlEventCount = dedupedDisplayEvents.length - transcriptEvents.length;
4994
5072
  const events = mergeSessionActionEvents(transcriptEvents, actionEvents).slice(-tail);
4995
5073
  const autoView = recordSessionReadViews(normalizedSessionId, events, {
4996
5074
  targetPath,
@@ -5040,6 +5118,7 @@ export function registerSessionCommand(program) {
5040
5118
  verified: Boolean(remoteTail.ok),
5041
5119
  appended: remoteTailAppended,
5042
5120
  displayedOnly: remoteTailDisplayedOnly,
5121
+ upgraded: remoteTailUpgraded,
5043
5122
  controlEventCount: remoteTailStats?.controlEventCount || 0,
5044
5123
  materialEventCount: remoteTailStats?.materialEventCount || 0,
5045
5124
  latestEvent: remoteTailStats?.latestEvent || null,
@@ -15,6 +15,24 @@ function timestampKey(...values) {
15
15
  return "";
16
16
  }
17
17
 
18
+ export function readSessionEventSequence(event = {}) {
19
+ const value = Number(event?.sequenceId ?? event?.sequence_id ?? event?.sequence);
20
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
21
+ }
22
+
23
+ export function sessionEventHasDurableCursor(event = {}) {
24
+ return Boolean(keyString(event?.cursor));
25
+ }
26
+
27
+ export function sessionEventUpgradesExisting(existingEvent = {}, candidateEvent = {}) {
28
+ const existingSequence = readSessionEventSequence(existingEvent);
29
+ const candidateSequence = readSessionEventSequence(candidateEvent);
30
+ if (candidateSequence > 0 && existingSequence <= 0) {
31
+ return true;
32
+ }
33
+ return sessionEventHasDurableCursor(candidateEvent) && !sessionEventHasDurableCursor(existingEvent);
34
+ }
35
+
18
36
  function stableJsonValue(value) {
19
37
  if (Array.isArray(value)) {
20
38
  return value.map((item) => stableJsonValue(item));
@@ -51,6 +69,10 @@ export function sessionEventIdentityKeys(event = {}) {
51
69
  keys.push(`idempotency:${event.idempotencyToken.trim()}`);
52
70
  }
53
71
  const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
72
+ const clientMessageId = typeof payload.clientMessageId === "string" ? payload.clientMessageId.trim() : "";
73
+ if (clientMessageId) {
74
+ keys.push(`client-message:${clientMessageId}`);
75
+ }
54
76
  const messageId = typeof payload.messageId === "string" ? payload.messageId.trim() : "";
55
77
  if (messageId) {
56
78
  keys.push(`message:${messageId}`);
@@ -29,20 +29,48 @@ import {
29
29
  import { cursorAdvances, readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
30
30
  import {
31
31
  addSessionEventIdentityKeys,
32
+ sessionEventIdentityKeys,
32
33
  sessionEventHasKnownIdentity,
34
+ sessionEventUpgradesExisting,
33
35
  } from "./event-identity.js";
34
36
 
35
37
  const EVENTS_CURSOR_SUFFIX = "events";
36
38
  const DEFAULT_EVENT_PAGE_LIMIT = 200;
37
39
  const DEFAULT_MAX_EVENT_PAGES = 25;
38
40
 
39
- async function readExistingRelayKeys(sessionId, { targetPath = process.cwd() } = {}) {
41
+ function indexRelayEventIdentityKeys(indexByKey, eventList = [], event = {}, index = -1) {
42
+ if (!indexByKey || !Array.isArray(eventList) || index < 0) return;
43
+ for (const key of sessionEventIdentityKeys(event)) {
44
+ const existingIndex = indexByKey.get(key);
45
+ const existingEvent = Number.isInteger(existingIndex) && existingIndex >= 0
46
+ ? eventList[existingIndex]
47
+ : null;
48
+ if (!existingEvent || sessionEventUpgradesExisting(existingEvent, event)) {
49
+ indexByKey.set(key, index);
50
+ }
51
+ }
52
+ }
53
+
54
+ function findRelayEventIdentityIndex(indexByKey, event = {}) {
55
+ if (!indexByKey) return -1;
56
+ for (const key of sessionEventIdentityKeys(event)) {
57
+ const index = indexByKey.get(key);
58
+ if (Number.isInteger(index) && index >= 0) {
59
+ return index;
60
+ }
61
+ }
62
+ return -1;
63
+ }
64
+
65
+ async function readExistingRelayState(sessionId, { targetPath = process.cwd() } = {}) {
40
66
  const knownKeys = new Set();
41
67
  const events = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
42
- for (const event of events) {
68
+ const indexByKey = new Map();
69
+ for (const [index, event] of events.entries()) {
43
70
  addSessionEventIdentityKeys(knownKeys, event);
71
+ indexRelayEventIdentityKeys(indexByKey, events, event, index);
44
72
  }
45
- return knownKeys;
73
+ return { knownKeys, events, indexByKey };
46
74
  }
47
75
 
48
76
  async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() } = {}) {
@@ -92,10 +120,13 @@ async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() }
92
120
  return { materialized: true, refreshed: false, session: created, remoteStatus };
93
121
  }
94
122
 
95
- function sourceFullyRelayed(events = [], successfulKeys = new Set()) {
123
+ function sourceFullyRelayed(events = [], successfulKeys = new Set(), failedKeys = new Set()) {
96
124
  const relayedEvents = Array.isArray(events) ? events : [];
97
125
  if (relayedEvents.length === 0) return true;
98
- return relayedEvents.every((event) => sessionEventHasKnownIdentity(event, successfulKeys));
126
+ return relayedEvents.every((event) =>
127
+ !sessionEventHasKnownIdentity(event, failedKeys) &&
128
+ sessionEventHasKnownIdentity(event, successfulKeys)
129
+ );
99
130
  }
100
131
 
101
132
  function markPostKillEvent(event = {}) {
@@ -341,11 +372,26 @@ export async function hydrateSessionFromRemote({
341
372
  let relayed = 0;
342
373
  let materializedLocalSession = false;
343
374
  let remoteStatus = "";
344
- const successfulRelayKeys =
345
- merged.length > 0 ? await readExistingRelayKeys(sessionId, { targetPath }) : new Set();
346
- const newEvents = successfulRelayKeys.size > 0
347
- ? merged.filter((event) => !sessionEventHasKnownIdentity(event, successfulRelayKeys))
348
- : merged;
375
+ const relayState = merged.length > 0
376
+ ? await readExistingRelayState(sessionId, { targetPath })
377
+ : { knownKeys: new Set(), events: [], indexByKey: new Map() };
378
+ const successfulRelayKeys = relayState.knownKeys;
379
+ const existingRelayEvents = relayState.events;
380
+ const existingRelayIndexByKey = relayState.indexByKey;
381
+ const newEvents = [];
382
+ for (const event of merged) {
383
+ const existingIndex = findRelayEventIdentityIndex(existingRelayIndexByKey, event);
384
+ if (existingIndex >= 0) {
385
+ const existing = existingRelayEvents[existingIndex];
386
+ if (sessionEventUpgradesExisting(existing, event)) {
387
+ newEvents.push(event);
388
+ }
389
+ continue;
390
+ }
391
+ if (!sessionEventHasKnownIdentity(event, successfulRelayKeys)) {
392
+ newEvents.push(event);
393
+ }
394
+ }
349
395
  if (newEvents.length > 0) {
350
396
  try {
351
397
  const localSession = await _ensureLocalSession(sessionId, { targetPath });
@@ -361,20 +407,30 @@ export async function hydrateSessionFromRemote({
361
407
  remoteStatus && !["active", "pending"].includes(remoteStatus)
362
408
  ? newEvents.map((event) => markPostKillEvent(event))
363
409
  : newEvents;
410
+ const failedRelayKeys = new Set();
364
411
  for (const event of appendEvents) {
365
412
  try {
366
- await _append(sessionId, event, { targetPath, syncRemote: false });
413
+ const persisted = await _append(sessionId, event, { targetPath, syncRemote: false });
367
414
  relayed += 1;
368
- addSessionEventIdentityKeys(successfulRelayKeys, event);
415
+ const persistedEvent = persisted && typeof persisted === "object" ? persisted : event;
416
+ existingRelayEvents.push(persistedEvent);
417
+ addSessionEventIdentityKeys(successfulRelayKeys, persistedEvent);
418
+ indexRelayEventIdentityKeys(
419
+ existingRelayIndexByKey,
420
+ existingRelayEvents,
421
+ persistedEvent,
422
+ existingRelayEvents.length - 1,
423
+ );
369
424
  } catch {
425
+ addSessionEventIdentityKeys(failedRelayKeys, event);
370
426
  // Append errors are observable via the stream but should not
371
427
  // abort the rest of the batch — partial relay is still progress.
372
428
  }
373
429
  }
374
430
 
375
431
  let persistedCursor = false;
376
- const humanCursorSafe = sourceFullyRelayed(humanResult?.events || [], successfulRelayKeys);
377
- const eventsCursorSafe = sourceFullyRelayed(eventsResult?.events || [], successfulRelayKeys);
432
+ const humanCursorSafe = sourceFullyRelayed(humanResult?.events || [], successfulRelayKeys, failedRelayKeys);
433
+ const eventsCursorSafe = sourceFullyRelayed(eventsResult?.events || [], successfulRelayKeys, failedRelayKeys);
378
434
  if (humanCursorSafe && typeof humanResult?.cursor === "string" && humanResult.cursor.trim()) {
379
435
  const result = await writeSyncCursor(sessionId, humanResult.cursor, { targetPath }).catch(() => null);
380
436
  persistedCursor = Boolean(result && result.written);