sentinelayer-cli 0.9.3 → 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.3",
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";
@@ -13,6 +15,14 @@ const DEFAULT_RECAP_MAX_EVENTS = 100;
13
15
  const DEFAULT_RECAP_INTERVAL_MS = 300_000;
14
16
  const DEFAULT_RECAP_INACTIVITY_MS = 600_000;
15
17
  const DEFAULT_RECAP_ACTIVITY_THRESHOLD = 5;
18
+ const DEFAULT_TASK_SUMMARY_LIMIT = 3;
19
+ const ACTIVE_TASK_STATUSES = new Set(["PENDING", "ACCEPTED", "BLOCKED"]);
20
+ const TASK_STATUS_KEYS = {
21
+ PENDING: "pending",
22
+ ACCEPTED: "accepted",
23
+ COMPLETED: "completed",
24
+ BLOCKED: "blocked",
25
+ };
16
26
 
17
27
  const ACTIVE_RECAP_EMITTERS = new Map();
18
28
 
@@ -160,11 +170,123 @@ async function readPendingTasks(sessionId, { forAgentId = "", targetPath = proce
160
170
  }
161
171
  }
162
172
 
163
- function buildElapsedMinutes(events = [], nowIso = new Date().toISOString()) {
164
- if (!Array.isArray(events) || events.length === 0) {
173
+ function normalizeTaskStatus(value) {
174
+ const normalized = normalizeString(value).toUpperCase();
175
+ return Object.prototype.hasOwnProperty.call(TASK_STATUS_KEYS, normalized)
176
+ ? normalized
177
+ : "PENDING";
178
+ }
179
+
180
+ function taskOwner(task = {}) {
181
+ return (
182
+ normalizeString(task.toAgentId) ||
183
+ normalizeString(task.requestedToAgentId) ||
184
+ (normalizeString(task.roleFilter) ? `role:${normalizeString(task.roleFilter)}` : "") ||
185
+ "unassigned"
186
+ );
187
+ }
188
+
189
+ function shortTaskText(value) {
190
+ const text = normalizeString(value).replace(/\s+/g, " ");
191
+ if (text.length <= 80) {
192
+ return text;
193
+ }
194
+ return `${text.slice(0, 77)}...`;
195
+ }
196
+
197
+ function emptyTaskLedgerSummary() {
198
+ return {
199
+ total: 0,
200
+ active: 0,
201
+ pending: 0,
202
+ accepted: 0,
203
+ blocked: 0,
204
+ completed: 0,
205
+ owners: [],
206
+ recent: [],
207
+ };
208
+ }
209
+
210
+ function summarizeTaskLedger(tasks = [], { limit = DEFAULT_TASK_SUMMARY_LIMIT } = {}) {
211
+ const summary = emptyTaskLedgerSummary();
212
+ const owners = new Map();
213
+ const normalizedTasks = [];
214
+
215
+ for (const task of Array.isArray(tasks) ? tasks : []) {
216
+ if (!task || typeof task !== "object") {
217
+ continue;
218
+ }
219
+ const status = normalizeTaskStatus(task.status);
220
+ const statusKey = TASK_STATUS_KEYS[status];
221
+ const owner = taskOwner(task);
222
+ const priority = normalizeString(task.priority) || "when-free";
223
+ const taskId = normalizeString(task.taskId) || "task";
224
+ const updatedAt = normalizeIsoTimestamp(
225
+ task.updatedAt || task.completedAt || task.acceptedAt || task.createdAt,
226
+ new Date().toISOString(),
227
+ );
228
+ const record = {
229
+ taskId,
230
+ status,
231
+ priority,
232
+ owner,
233
+ task: shortTaskText(task.task),
234
+ updatedAt,
235
+ };
236
+ normalizedTasks.push(record);
237
+ summary.total += 1;
238
+ summary[statusKey] += 1;
239
+
240
+ if (ACTIVE_TASK_STATUSES.has(status)) {
241
+ summary.active += 1;
242
+ const ownerRecord = owners.get(owner) || {
243
+ agentId: owner,
244
+ active: 0,
245
+ pending: 0,
246
+ accepted: 0,
247
+ blocked: 0,
248
+ };
249
+ ownerRecord.active += 1;
250
+ ownerRecord[statusKey] += 1;
251
+ owners.set(owner, ownerRecord);
252
+ }
253
+ }
254
+
255
+ summary.owners = [...owners.values()]
256
+ .sort((left, right) => {
257
+ if (right.active !== left.active) return right.active - left.active;
258
+ return left.agentId.localeCompare(right.agentId);
259
+ })
260
+ .slice(0, Math.max(1, limit));
261
+ summary.recent = normalizedTasks
262
+ .sort((left, right) => toEpoch(right.updatedAt) - toEpoch(left.updatedAt))
263
+ .slice(0, Math.max(1, limit));
264
+ return summary;
265
+ }
266
+
267
+ async function readTaskLedgerSummary(
268
+ sessionId,
269
+ { targetPath = process.cwd(), limit = DEFAULT_TASK_SUMMARY_LIMIT } = {},
270
+ ) {
271
+ const paths = resolveSessionPaths(sessionId, { targetPath });
272
+ try {
273
+ const raw = await fsp.readFile(paths.tasksPath, "utf-8");
274
+ const parsed = JSON.parse(raw);
275
+ return summarizeTaskLedger(parsed?.tasks || [], { limit });
276
+ } catch (error) {
277
+ if (error && typeof error === "object" && error.code === "ENOENT") {
278
+ return emptyTaskLedgerSummary();
279
+ }
280
+ return emptyTaskLedgerSummary();
281
+ }
282
+ }
283
+
284
+ function elapsedMinutesBetween(startIso, nowIso = new Date().toISOString()) {
285
+ const normalizedStartIso = normalizeString(startIso);
286
+ if (!normalizedStartIso) {
165
287
  return 0;
166
288
  }
167
- const firstEpoch = toEpoch(events[0]?.ts, nowIso);
289
+ const firstEpoch = toEpoch(normalizedStartIso, nowIso);
168
290
  const nowEpoch = toEpoch(nowIso, nowIso);
169
291
  if (!Number.isFinite(firstEpoch) || !Number.isFinite(nowEpoch) || nowEpoch <= firstEpoch) {
170
292
  return 0;
@@ -172,6 +294,51 @@ function buildElapsedMinutes(events = [], nowIso = new Date().toISOString()) {
172
294
  return Math.max(0, Math.floor((nowEpoch - firstEpoch) / 60_000));
173
295
  }
174
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
+
175
342
  function buildRecapKey(sessionId, targetPath) {
176
343
  return `${path.resolve(String(targetPath || "."))}::${normalizeString(sessionId)}`;
177
344
  }
@@ -181,6 +348,7 @@ function buildRecapText({
181
348
  totalFindings = 0,
182
349
  activeLocks = 0,
183
350
  pendingTasks = 0,
351
+ taskLedger = emptyTaskLedgerSummary(),
184
352
  snippets = [],
185
353
  } = {}) {
186
354
  const agentText =
@@ -191,13 +359,49 @@ function buildRecapText({
191
359
  const lockText = `${activeLocks} file lock${activeLocks === 1 ? "" : "s"} active`;
192
360
  const pendingText =
193
361
  pendingTasks > 0 ? `You have ${pendingTasks} pending task${pendingTasks === 1 ? "" : "s"}.` : "";
362
+ const taskText = buildTaskLedgerText(taskLedger);
194
363
  const snippetText = snippets.length > 0 ? `Recent: ${snippets.join(" | ")}` : "";
195
- return `While you were away: ${agentText}. ${findingText}. ${lockText}. ${pendingText} ${snippetText}`.replace(
364
+ return `While you were away: ${agentText}. ${findingText}. ${lockText}. ${pendingText} ${taskText}. ${snippetText}`.replace(
196
365
  /\s+/g,
197
366
  " "
198
367
  ).trim();
199
368
  }
200
369
 
370
+ function buildTaskLedgerText(taskLedger = emptyTaskLedgerSummary()) {
371
+ const total = Number(taskLedger.total || 0);
372
+ if (!total) {
373
+ return "Tasks: none queued";
374
+ }
375
+ const active = Number(taskLedger.active || 0);
376
+ const counts = [
377
+ `${Number(taskLedger.pending || 0)} pending`,
378
+ `${Number(taskLedger.accepted || 0)} accepted`,
379
+ `${Number(taskLedger.blocked || 0)} blocked`,
380
+ `${Number(taskLedger.completed || 0)} done`,
381
+ ].join(", ");
382
+ const ownerText =
383
+ Array.isArray(taskLedger.owners) && taskLedger.owners.length > 0
384
+ ? `Owners: ${taskLedger.owners
385
+ .map((owner) => {
386
+ const parts = [];
387
+ if (owner.pending) parts.push(`${owner.pending} pending`);
388
+ if (owner.accepted) parts.push(`${owner.accepted} accepted`);
389
+ if (owner.blocked) parts.push(`${owner.blocked} blocked`);
390
+ return `${owner.agentId} (${parts.join("/") || `${owner.active || 0} active`})`;
391
+ })
392
+ .join("; ")}`
393
+ : "Owners: none active";
394
+ const recentText =
395
+ Array.isArray(taskLedger.recent) && taskLedger.recent.length > 0
396
+ ? `Recent tasks: ${taskLedger.recent
397
+ .map((task) => `${task.priority} ${task.status} ${task.owner}: ${task.task}`)
398
+ .join(" | ")}`
399
+ : "";
400
+ return [`Tasks: ${active} active of ${total} total (${counts})`, ownerText, recentText]
401
+ .filter(Boolean)
402
+ .join(". ");
403
+ }
404
+
201
405
  // Multi-agent session etiquette + read-path rules surfaced in the
202
406
  // context_briefing payload an agent receives on first join. Web
203
407
  // renders this as markdown (see sentinelayer-web Session.tsx
@@ -235,7 +439,8 @@ function buildPeriodicText(recap = {}) {
235
439
  const activeLocks = Number(summary.activeLocks || 0);
236
440
  const lastActor = normalizeString(summary.lastActorId);
237
441
  const actorText = lastActor ? `${lastActor} active` : "no active actor";
238
- return `Session active for ${elapsedMinutes}m. ${activeAgents} agents. ${totalFindings} findings. ${activeLocks} locks. ${actorText}.`;
442
+ const taskText = buildTaskLedgerText(summary.taskLedger);
443
+ return `Session active for ${elapsedMinutes}m. ${activeAgents} agents. ${totalFindings} findings. ${activeLocks} locks. ${taskText}. ${actorText}.`;
239
444
  }
240
445
 
241
446
  export async function buildSessionRecap(
@@ -256,10 +461,19 @@ export async function buildSessionRecap(
256
461
  const normalizedMaxEvents = normalizePositiveInteger(maxEvents, DEFAULT_RECAP_MAX_EVENTS);
257
462
  const normalizedForAgentId = normalizeString(forAgentId);
258
463
 
259
- const events = await readStream(normalizedSessionId, {
464
+ const allEvents = await readStream(normalizedSessionId, {
260
465
  targetPath: normalizedTargetPath,
261
- tail: normalizedMaxEvents,
466
+ tail: 0,
262
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
+ );
263
477
  const visibleEvents = (Array.isArray(events) ? events : []).filter((event) => {
264
478
  const agentId = normalizeString(event.agent?.id || event.agentId);
265
479
  if (!agentId) {
@@ -288,17 +502,24 @@ export async function buildSessionRecap(
288
502
  forAgentId: normalizedForAgentId,
289
503
  targetPath: normalizedTargetPath,
290
504
  });
505
+ const taskLedger = await readTaskLedgerSummary(normalizedSessionId, {
506
+ targetPath: normalizedTargetPath,
507
+ });
291
508
  const snippets = summarizeRecentActivity(visibleEvents, {
292
509
  forAgentId: normalizedForAgentId,
293
510
  limit: 2,
294
511
  });
295
- const elapsedMinutes = buildElapsedMinutes(visibleEvents, normalizedNow);
512
+ const windowElapsedMinutes = buildElapsedMinutes(visibleEvents, normalizedNow);
513
+ const elapsedMinutes = buildElapsedMinutes(visibleEvents, normalizedNow, {
514
+ startedAt: sessionMetadata?.createdAt,
515
+ });
296
516
  const latestEvent = visibleEvents.length > 0 ? visibleEvents[visibleEvents.length - 1] : null;
297
517
  const recapText = buildRecapText({
298
518
  activeAgents,
299
519
  totalFindings: totalFindingsCount,
300
520
  activeLocks,
301
521
  pendingTasks,
522
+ taskLedger,
302
523
  snippets,
303
524
  });
304
525
 
@@ -317,8 +538,13 @@ export async function buildSessionRecap(
317
538
  totalFindingsCount,
318
539
  activeLocks,
319
540
  pendingTasksForAgent: pendingTasks,
541
+ taskLedger,
320
542
  snippets,
321
543
  elapsedMinutes,
544
+ windowElapsedMinutes,
545
+ sessionStartedAt: sessionMetadata?.createdAt
546
+ ? normalizeIsoTimestamp(sessionMetadata.createdAt, normalizedNow)
547
+ : null,
322
548
  lastActorId: normalizeString(latestEvent?.agent?.id || latestEvent?.agentId) || null,
323
549
  lastEventAt: latestEvent ? normalizeIsoTimestamp(latestEvent.ts, normalizedNow) : null,
324
550
  },