sentinelayer-cli 0.9.4 → 0.9.5

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.9.4",
3
+ "version": "0.9.5",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -68,6 +68,7 @@ import {
68
68
  import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
69
69
  import { mergeLiveSources } from "../session/live-source.js";
70
70
  import { listenSessionEvents } from "../session/listener.js";
71
+ import { buildSessionRecap } from "../session/recap.js";
71
72
  import { deriveSessionTitle } from "../session/senti-naming.js";
72
73
  import { pushSessionTitleToApi } from "../session/title-sync.js";
73
74
  import {
@@ -560,6 +561,51 @@ function formatEventLine(event = {}) {
560
561
  return `${ts} ${agentId} ${type}`;
561
562
  }
562
563
 
564
+ async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetPath } = {}) {
565
+ const events = Array.isArray(remoteEvents) ? remoteEvents : [];
566
+ if (events.length === 0) {
567
+ return {
568
+ appended: 0,
569
+ skipped: 0,
570
+ failed: 0,
571
+ };
572
+ }
573
+ const knownKeys = new Set();
574
+ const localEvents = await readStream(sessionId, {
575
+ targetPath,
576
+ tail: 0,
577
+ });
578
+ for (const event of localEvents) {
579
+ addSessionEventIdentityKeys(knownKeys, event);
580
+ }
581
+
582
+ let appended = 0;
583
+ let skipped = 0;
584
+ let failed = 0;
585
+ for (const event of events) {
586
+ if (sessionEventHasKnownIdentity(event, knownKeys)) {
587
+ skipped += 1;
588
+ continue;
589
+ }
590
+ try {
591
+ const persisted = await appendToStream(sessionId, event, {
592
+ targetPath,
593
+ syncRemote: false,
594
+ });
595
+ addSessionEventIdentityKeys(knownKeys, persisted);
596
+ appended += 1;
597
+ } catch {
598
+ addSessionEventIdentityKeys(knownKeys, event);
599
+ failed += 1;
600
+ }
601
+ }
602
+ return {
603
+ appended,
604
+ skipped,
605
+ failed,
606
+ };
607
+ }
608
+
563
609
  function formatTemplateLaunchLine(slot = {}) {
564
610
  const terminal = Number(slot.terminal || 0);
565
611
  const role = normalizeString(slot.role) || "agent";
@@ -1479,6 +1525,92 @@ export function registerSessionCommand(program) {
1479
1525
  }
1480
1526
  });
1481
1527
 
1528
+ const recap = session
1529
+ .command("recap")
1530
+ .description("Build deterministic Senti session recaps");
1531
+
1532
+ recap
1533
+ .command("now [sessionId]")
1534
+ .description("Summarize current session activity, peers, findings, locks, and task ownership")
1535
+ .option("--session <id>", "Session id to recap")
1536
+ .option(
1537
+ "--remote",
1538
+ "Hydrate the latest durable API events before building the recap",
1539
+ )
1540
+ .option(
1541
+ "--agent <id>",
1542
+ "Agent id requesting the recap; self-authored events are omitted from recent snippets",
1543
+ process.env.SENTINELAYER_AGENT_ID || "",
1544
+ )
1545
+ .option("--max-events <n>", "Maximum recent local events to inspect (default 100)", "100")
1546
+ .option("--path <path>", "Workspace path for the session", ".")
1547
+ .option("--json", "Emit machine-readable output")
1548
+ .action(async (sessionId, options, command) => {
1549
+ const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
1550
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
1551
+ const agentId = normalizeAgentId(options.agent, "");
1552
+ const maxEvents = parsePositiveInteger(options.maxEvents, "max-events", 100);
1553
+ let hydration = null;
1554
+ let remoteTail = null;
1555
+ let remoteAppend = null;
1556
+ if (options.remote) {
1557
+ hydration = await hydrateSessionFromRemote({
1558
+ sessionId: normalizedSessionId,
1559
+ targetPath,
1560
+ });
1561
+ remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
1562
+ targetPath,
1563
+ limit: maxEvents,
1564
+ timeoutMs: 15_000,
1565
+ });
1566
+ if (remoteTail?.ok && Array.isArray(remoteTail.events) && remoteTail.events.length > 0) {
1567
+ remoteAppend = await appendMissingRemoteEvents(normalizedSessionId, remoteTail.events, {
1568
+ targetPath,
1569
+ });
1570
+ }
1571
+ }
1572
+ const current = await buildSessionRecap(normalizedSessionId, {
1573
+ forAgentId: agentId,
1574
+ maxEvents,
1575
+ targetPath,
1576
+ });
1577
+ const payload = {
1578
+ command: "session recap now",
1579
+ targetPath,
1580
+ sessionId: normalizedSessionId,
1581
+ agentId: current.forAgentId,
1582
+ maxEvents,
1583
+ generatedAt: current.generatedAt,
1584
+ ephemeral: current.ephemeral,
1585
+ style: current.style,
1586
+ recap: current.text,
1587
+ summary: current.summary,
1588
+ remote: options.remote
1589
+ ? {
1590
+ hydration,
1591
+ tailProbe: remoteTail
1592
+ ? {
1593
+ ok: Boolean(remoteTail.ok),
1594
+ reason: remoteTail.reason || "",
1595
+ count: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
1596
+ cursor: remoteTail.cursor || null,
1597
+ }
1598
+ : null,
1599
+ appendedTail: remoteAppend,
1600
+ }
1601
+ : null,
1602
+ };
1603
+ if (shouldEmitJson(options, command)) {
1604
+ console.log(JSON.stringify(payload, null, 2));
1605
+ return;
1606
+ }
1607
+ console.log(pc.bold(`Recap for session ${normalizedSessionId}`));
1608
+ if (payload.agentId) {
1609
+ console.log(pc.gray(`for agent=${payload.agentId}`));
1610
+ }
1611
+ console.log(current.text);
1612
+ });
1613
+
1482
1614
  session
1483
1615
  .command("read <sessionId>")
1484
1616
  .description("Read recent session messages")
@@ -3,8 +3,10 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
 
5
5
  import { createAgentEvent } from "../events/schema.js";
6
+ import { dedupeSessionEvents } from "./event-identity.js";
6
7
  import { resolveSessionPaths } from "./paths.js";
7
8
  import { appendToStream, readStream } from "./stream.js";
9
+ import { getSession } from "./store.js";
8
10
 
9
11
  const SENTI_AGENT_ID = "senti";
10
12
  const SENTI_MODEL = "gpt-5.4-mini";
@@ -279,11 +281,12 @@ async function readTaskLedgerSummary(
279
281
  }
280
282
  }
281
283
 
282
- function buildElapsedMinutes(events = [], nowIso = new Date().toISOString()) {
283
- if (!Array.isArray(events) || events.length === 0) {
284
+ function elapsedMinutesBetween(startIso, nowIso = new Date().toISOString()) {
285
+ const normalizedStartIso = normalizeString(startIso);
286
+ if (!normalizedStartIso) {
284
287
  return 0;
285
288
  }
286
- const firstEpoch = toEpoch(events[0]?.ts, nowIso);
289
+ const firstEpoch = toEpoch(normalizedStartIso, nowIso);
287
290
  const nowEpoch = toEpoch(nowIso, nowIso);
288
291
  if (!Number.isFinite(firstEpoch) || !Number.isFinite(nowEpoch) || nowEpoch <= firstEpoch) {
289
292
  return 0;
@@ -291,6 +294,51 @@ function buildElapsedMinutes(events = [], nowIso = new Date().toISOString()) {
291
294
  return Math.max(0, Math.floor((nowEpoch - firstEpoch) / 60_000));
292
295
  }
293
296
 
297
+ function earliestIso(values = [], fallbackIso = new Date().toISOString()) {
298
+ const validEpochs = values
299
+ .map((value) => normalizeString(value))
300
+ .filter(Boolean)
301
+ .map((value) => Date.parse(value))
302
+ .filter((value) => Number.isFinite(value));
303
+ if (validEpochs.length === 0) {
304
+ return "";
305
+ }
306
+ validEpochs.sort((left, right) => left - right);
307
+ return normalizeIsoTimestamp(new Date(validEpochs[0]).toISOString(), fallbackIso);
308
+ }
309
+
310
+ function buildElapsedMinutes(events = [], nowIso = new Date().toISOString(), { startedAt = "" } = {}) {
311
+ const eventStart = Array.isArray(events) && events.length > 0 ? events[0]?.ts || events[0]?.timestamp : "";
312
+ const startIso = earliestIso([startedAt, eventStart], nowIso);
313
+ return elapsedMinutesBetween(startIso, nowIso);
314
+ }
315
+
316
+ function eventSequenceNumber(event = {}) {
317
+ for (const value of [event.sequenceId, event.sequence, event.seq, event.payload?.sequenceId]) {
318
+ const normalized = Number(value);
319
+ if (Number.isFinite(normalized)) {
320
+ return normalized;
321
+ }
322
+ }
323
+ return 0;
324
+ }
325
+
326
+ function sortEventsByConversationTime(events = [], fallbackIso = new Date().toISOString()) {
327
+ return [...(Array.isArray(events) ? events : [])].sort((left, right) => {
328
+ const leftEpoch = toEpoch(left?.ts || left?.timestamp, fallbackIso);
329
+ const rightEpoch = toEpoch(right?.ts || right?.timestamp, fallbackIso);
330
+ if (leftEpoch !== rightEpoch) {
331
+ return leftEpoch - rightEpoch;
332
+ }
333
+ const leftSequence = eventSequenceNumber(left);
334
+ const rightSequence = eventSequenceNumber(right);
335
+ if (leftSequence !== rightSequence) {
336
+ return leftSequence - rightSequence;
337
+ }
338
+ return normalizeString(left?.cursor).localeCompare(normalizeString(right?.cursor));
339
+ });
340
+ }
341
+
294
342
  function buildRecapKey(sessionId, targetPath) {
295
343
  return `${path.resolve(String(targetPath || "."))}::${normalizeString(sessionId)}`;
296
344
  }
@@ -413,10 +461,19 @@ export async function buildSessionRecap(
413
461
  const normalizedMaxEvents = normalizePositiveInteger(maxEvents, DEFAULT_RECAP_MAX_EVENTS);
414
462
  const normalizedForAgentId = normalizeString(forAgentId);
415
463
 
416
- const events = await readStream(normalizedSessionId, {
464
+ const allEvents = await readStream(normalizedSessionId, {
417
465
  targetPath: normalizedTargetPath,
418
- tail: normalizedMaxEvents,
466
+ tail: 0,
419
467
  });
468
+ let sessionMetadata = null;
469
+ try {
470
+ sessionMetadata = await getSession(normalizedSessionId, { targetPath: normalizedTargetPath });
471
+ } catch {
472
+ sessionMetadata = null;
473
+ }
474
+ const events = sortEventsByConversationTime(dedupeSessionEvents(allEvents), normalizedNow).slice(
475
+ -normalizedMaxEvents,
476
+ );
420
477
  const visibleEvents = (Array.isArray(events) ? events : []).filter((event) => {
421
478
  const agentId = normalizeString(event.agent?.id || event.agentId);
422
479
  if (!agentId) {
@@ -452,7 +509,10 @@ export async function buildSessionRecap(
452
509
  forAgentId: normalizedForAgentId,
453
510
  limit: 2,
454
511
  });
455
- const elapsedMinutes = buildElapsedMinutes(visibleEvents, normalizedNow);
512
+ const windowElapsedMinutes = buildElapsedMinutes(visibleEvents, normalizedNow);
513
+ const elapsedMinutes = buildElapsedMinutes(visibleEvents, normalizedNow, {
514
+ startedAt: sessionMetadata?.createdAt,
515
+ });
456
516
  const latestEvent = visibleEvents.length > 0 ? visibleEvents[visibleEvents.length - 1] : null;
457
517
  const recapText = buildRecapText({
458
518
  activeAgents,
@@ -481,6 +541,10 @@ export async function buildSessionRecap(
481
541
  taskLedger,
482
542
  snippets,
483
543
  elapsedMinutes,
544
+ windowElapsedMinutes,
545
+ sessionStartedAt: sessionMetadata?.createdAt
546
+ ? normalizeIsoTimestamp(sessionMetadata.createdAt, normalizedNow)
547
+ : null,
484
548
  lastActorId: normalizeString(latestEvent?.agent?.id || latestEvent?.agentId) || null,
485
549
  lastEventAt: latestEvent ? normalizeIsoTimestamp(latestEvent.ts, normalizedNow) : null,
486
550
  },