sentinelayer-cli 0.31.0 → 0.31.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.31.0",
3
+ "version": "0.31.1",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -67,11 +67,18 @@ async function readExistingRelayState(sessionId, { targetPath = process.cwd() }
67
67
  const knownKeys = new Set();
68
68
  const events = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
69
69
  const indexByKey = new Map();
70
+ const duplicateKeys = new Set();
70
71
  for (const [index, event] of events.entries()) {
72
+ for (const key of sessionEventIdentityKeys(event)) {
73
+ const existingIndex = indexByKey.get(key);
74
+ if (Number.isInteger(existingIndex) && existingIndex >= 0 && existingIndex !== index) {
75
+ duplicateKeys.add(key);
76
+ }
77
+ }
71
78
  addSessionEventIdentityKeys(knownKeys, event);
72
79
  indexRelayEventIdentityKeys(indexByKey, events, event, index);
73
80
  }
74
- return { knownKeys, events, indexByKey };
81
+ return { knownKeys, events, indexByKey, duplicateKeys };
75
82
  }
76
83
 
77
84
  async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() } = {}) {
@@ -380,13 +387,19 @@ export async function hydrateSessionFromRemote({
380
387
  const successfulRelayKeys = relayState.knownKeys;
381
388
  const existingRelayEvents = relayState.events;
382
389
  const existingRelayIndexByKey = relayState.indexByKey;
390
+ const duplicateRelayKeys = relayState.duplicateKeys || new Set();
383
391
  const newEvents = [];
392
+ const eventsNeedingMerge = new Set();
384
393
  for (const event of merged) {
385
394
  const existingIndex = findRelayEventIdentityIndex(existingRelayIndexByKey, event);
386
395
  if (existingIndex >= 0) {
387
396
  const existing = existingRelayEvents[existingIndex];
388
- if (sessionEventUpgradesExisting(existing, event)) {
397
+ if (
398
+ sessionEventUpgradesExisting(existing, event) ||
399
+ sessionEventHasKnownIdentity(event, duplicateRelayKeys)
400
+ ) {
389
401
  newEvents.push(event);
402
+ eventsNeedingMerge.add(event);
390
403
  }
391
404
  continue;
392
405
  }
@@ -416,14 +429,20 @@ export async function hydrateSessionFromRemote({
416
429
  }
417
430
  }
418
431
 
419
- const appendEvents =
420
- remoteStatus && !["active", "pending"].includes(remoteStatus)
421
- ? materialNewEvents.map((event) => markPostKillEvent(event))
422
- : materialNewEvents;
432
+ const appendEvents = materialNewEvents.map((event) => ({
433
+ event: remoteStatus && !["active", "pending"].includes(remoteStatus)
434
+ ? markPostKillEvent(event)
435
+ : event,
436
+ mergeExisting: eventsNeedingMerge.has(event),
437
+ }));
423
438
  const failedRelayKeys = new Set();
424
- for (const event of appendEvents) {
439
+ for (const { event, mergeExisting } of appendEvents) {
425
440
  try {
426
- const persisted = await _append(sessionId, event, { targetPath, syncRemote: false });
441
+ const persisted = await _append(sessionId, event, {
442
+ targetPath,
443
+ syncRemote: false,
444
+ mergeExisting,
445
+ });
427
446
  relayed += 1;
428
447
  const persistedEvent = persisted && typeof persisted === "object" ? persisted : event;
429
448
  existingRelayEvents.push(persistedEvent);
@@ -3,6 +3,10 @@ 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 {
7
+ dedupeSessionEvents,
8
+ sessionEventIdentityKeys,
9
+ } from "./event-identity.js";
6
10
  import { enrichEventWithMentions } from "./mentions.js";
7
11
  import { resolveSessionPaths } from "./paths.js";
8
12
  import { redactEventPayload } from "./redact.js";
@@ -348,6 +352,58 @@ async function rotateStreamIfNeeded(paths, maxEvents) {
348
352
  await fsp.writeFile(paths.streamPath, retainedPayload, "utf-8");
349
353
  }
350
354
 
355
+ function eventsShareIdentity(left = {}, right = {}) {
356
+ const leftKeys = new Set(sessionEventIdentityKeys(left));
357
+ if (leftKeys.size === 0) return false;
358
+ return sessionEventIdentityKeys(right).some((key) => leftKeys.has(key));
359
+ }
360
+
361
+ async function appendOrMergeCurrentStreamEvent(paths, canonicalEvent, { mergeExisting = false } = {}) {
362
+ if (!mergeExisting) {
363
+ await fsp.appendFile(paths.streamPath, `${JSON.stringify(canonicalEvent)}\n`, "utf-8");
364
+ return;
365
+ }
366
+
367
+ const currentEvents = await readEventsFromFile(paths.streamPath);
368
+ const matchingIndexes = [];
369
+ for (const [index, event] of currentEvents.entries()) {
370
+ if (eventsShareIdentity(event, canonicalEvent)) {
371
+ matchingIndexes.push(index);
372
+ }
373
+ }
374
+
375
+ if (matchingIndexes.length === 0) {
376
+ await fsp.appendFile(paths.streamPath, `${JSON.stringify(canonicalEvent)}\n`, "utf-8");
377
+ return;
378
+ }
379
+
380
+ const replacement = dedupeSessionEvents([
381
+ ...matchingIndexes.map((index) => currentEvents[index]),
382
+ canonicalEvent,
383
+ ])[0] || canonicalEvent;
384
+ const firstMatch = matchingIndexes[0];
385
+ const matchingIndexSet = new Set(matchingIndexes);
386
+ const mergedEvents = [];
387
+ for (const [index, event] of currentEvents.entries()) {
388
+ if (index === firstMatch) {
389
+ mergedEvents.push(replacement);
390
+ continue;
391
+ }
392
+ if (matchingIndexSet.has(index)) {
393
+ continue;
394
+ }
395
+ mergedEvents.push(event);
396
+ }
397
+
398
+ await fsp.writeFile(
399
+ paths.streamPath,
400
+ mergedEvents.length > 0
401
+ ? `${mergedEvents.map((event) => JSON.stringify(event)).join("\n")}\n`
402
+ : "",
403
+ "utf-8",
404
+ );
405
+ }
406
+
351
407
  function filterBySince(events = [], since) {
352
408
  const normalizedSince = normalizeString(since);
353
409
  if (!normalizedSince) {
@@ -371,6 +427,7 @@ export async function appendToStream(
371
427
  maxEvents = DEFAULT_MAX_STREAM_EVENTS,
372
428
  syncRemote = true,
373
429
  awaitRemoteSync = false,
430
+ mergeExisting = false,
374
431
  } = {}
375
432
  ) {
376
433
  const paths = resolveSessionPaths(sessionId, { targetPath });
@@ -403,7 +460,7 @@ export async function appendToStream(
403
460
  await acquireLock(paths.lockPath);
404
461
  try {
405
462
  await fsp.mkdir(paths.sessionDir, { recursive: true });
406
- await fsp.appendFile(paths.streamPath, `${JSON.stringify(canonicalEvent)}\n`, "utf-8");
463
+ await appendOrMergeCurrentStreamEvent(paths, canonicalEvent, { mergeExisting });
407
464
  await rotateStreamIfNeeded(paths, normalizedMaxEvents);
408
465
 
409
466
  metadata.lastInteractionAt = nowIso;