sentinelayer-cli 0.19.0 → 0.21.0
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 +1 -1
- package/src/commands/ai/identity-lifecycle.js +14 -2
- package/src/commands/mcp.js +60 -0
- package/src/commands/session.js +1459 -31
- package/src/legacy-cli.js +18 -11
- package/src/mcp/registry.js +151 -0
- package/src/mcp/session-stdio-server.js +977 -0
- package/src/scan/generator.js +3 -2
- package/src/session/agent-registry.js +118 -0
- package/src/session/checkpoints.js +71 -1
- package/src/session/coordination-guidance.js +3 -2
- package/src/session/listener.js +302 -68
- package/src/session/pricing-ledger.js +34 -4
- package/src/session/recap.js +4 -2
- package/src/session/sync.js +395 -0
- package/src/session/transcript.js +86 -36
- package/src/session/usage.js +5 -5
- package/src/session/wake/claude.js +175 -0
- package/src/session/wake/codex.js +394 -0
- package/src/session/wake/cursor-store.js +69 -0
- package/src/session/wake/dispatcher.js +184 -0
- package/src/session/wake/pump.js +135 -0
- package/src/session/wake/registry.js +80 -0
- package/src/session/wake/resolve-target.js +146 -0
- package/src/session/wake/sentid.js +103 -0
package/src/session/sync.js
CHANGED
|
@@ -428,6 +428,95 @@ async function fetchWithTimeout(url, options, timeoutMs) {
|
|
|
428
428
|
}
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
function isAbortLike(error) {
|
|
432
|
+
return Boolean(error?.name === "AbortError" || error?.code === "ABORT_ERR");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function* readResponseTextChunks(response) {
|
|
436
|
+
const body = response?.body;
|
|
437
|
+
if (!body) return;
|
|
438
|
+
|
|
439
|
+
if (typeof body.getReader === "function") {
|
|
440
|
+
const reader = body.getReader();
|
|
441
|
+
try {
|
|
442
|
+
while (true) {
|
|
443
|
+
const { done, value } = await reader.read();
|
|
444
|
+
if (done) break;
|
|
445
|
+
if (value) yield value;
|
|
446
|
+
}
|
|
447
|
+
} finally {
|
|
448
|
+
try {
|
|
449
|
+
reader.releaseLock();
|
|
450
|
+
} catch {
|
|
451
|
+
// Best-effort cleanup only.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (typeof body[Symbol.asyncIterator] === "function") {
|
|
458
|
+
for await (const chunk of body) {
|
|
459
|
+
if (chunk) yield chunk;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function extractSseErrorReason(parsed) {
|
|
465
|
+
const error = parsed?.error && typeof parsed.error === "object" ? parsed.error : {};
|
|
466
|
+
return (
|
|
467
|
+
normalizeString(error.code) ||
|
|
468
|
+
normalizeString(error.message) ||
|
|
469
|
+
normalizeString(error.detail) ||
|
|
470
|
+
"session_stream_error"
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function processSseBlock(block, handlers) {
|
|
475
|
+
const normalizedBlock = normalizeString(block);
|
|
476
|
+
if (!normalizedBlock) return;
|
|
477
|
+
|
|
478
|
+
const dataLines = [];
|
|
479
|
+
let commentOnly = true;
|
|
480
|
+
for (const rawLine of String(block).split("\n")) {
|
|
481
|
+
const line = rawLine.trimEnd();
|
|
482
|
+
if (!line) continue;
|
|
483
|
+
if (line.startsWith(":")) continue;
|
|
484
|
+
commentOnly = false;
|
|
485
|
+
if (line.startsWith("data:")) {
|
|
486
|
+
dataLines.push(line.slice(5).trimStart());
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (dataLines.length === 0) {
|
|
491
|
+
if (commentOnly && typeof handlers.onHeartbeat === "function") {
|
|
492
|
+
await handlers.onHeartbeat();
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const rawData = dataLines.join("\n").trim();
|
|
498
|
+
if (!rawData) return;
|
|
499
|
+
|
|
500
|
+
let parsed;
|
|
501
|
+
try {
|
|
502
|
+
parsed = JSON.parse(rawData);
|
|
503
|
+
} catch {
|
|
504
|
+
await handlers.onError?.({ reason: "malformed_stream_event", cursor: handlers.cursor() });
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (parsed?.type === "error") {
|
|
509
|
+
await handlers.onError?.({
|
|
510
|
+
reason: extractSseErrorReason(parsed),
|
|
511
|
+
cursor: handlers.cursor(),
|
|
512
|
+
error: parsed.error || null,
|
|
513
|
+
});
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
await handlers.onEvent?.(parsed);
|
|
518
|
+
}
|
|
519
|
+
|
|
431
520
|
function sanitizeHumanMessage(rawMessage) {
|
|
432
521
|
const stripped = String(rawMessage || "")
|
|
433
522
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
|
|
@@ -1131,6 +1220,195 @@ export async function pollSessionEvents(
|
|
|
1131
1220
|
}
|
|
1132
1221
|
}
|
|
1133
1222
|
|
|
1223
|
+
/**
|
|
1224
|
+
* Consume the API's durable session SSE stream.
|
|
1225
|
+
*
|
|
1226
|
+
* This is the wakeup-first companion to `pollSessionEvents`: the stream uses
|
|
1227
|
+
* Redis wakeups server-side, while the listener can still fall back to durable
|
|
1228
|
+
* `/events` polling if the stream is unavailable or closes.
|
|
1229
|
+
*
|
|
1230
|
+
* @param {string} sessionId
|
|
1231
|
+
* @param {object} [options]
|
|
1232
|
+
* @param {string|null} [options.since] - durable cursor to resume after
|
|
1233
|
+
* @param {AbortSignal} [options.signal]
|
|
1234
|
+
* @param {(event: object) => Promise<void>|void} [options.onEvent]
|
|
1235
|
+
* @param {(payload: object) => Promise<void>|void} [options.onError]
|
|
1236
|
+
* @param {() => Promise<void>|void} [options.onHeartbeat]
|
|
1237
|
+
* @returns {Promise<{ok: boolean, reason: string, cursor: string|null, eventCount: number, errorCount: number, status?: number, aborted?: boolean}>}
|
|
1238
|
+
*/
|
|
1239
|
+
export async function streamSessionEvents(
|
|
1240
|
+
sessionId,
|
|
1241
|
+
{
|
|
1242
|
+
targetPath = process.cwd(),
|
|
1243
|
+
since = null,
|
|
1244
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1245
|
+
signal = undefined,
|
|
1246
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1247
|
+
fetchImpl = fetch,
|
|
1248
|
+
onEvent = async () => {},
|
|
1249
|
+
onError = async () => {},
|
|
1250
|
+
onHeartbeat = async () => {},
|
|
1251
|
+
} = {}
|
|
1252
|
+
) {
|
|
1253
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1254
|
+
const normalizedSince = normalizeString(since) || null;
|
|
1255
|
+
if (!normalizedSessionId) {
|
|
1256
|
+
return {
|
|
1257
|
+
ok: false,
|
|
1258
|
+
reason: "invalid_session_id",
|
|
1259
|
+
cursor: normalizedSince,
|
|
1260
|
+
eventCount: 0,
|
|
1261
|
+
errorCount: 0,
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
let session = null;
|
|
1266
|
+
try {
|
|
1267
|
+
session = await resolveAuthSession({
|
|
1268
|
+
cwd: targetPath,
|
|
1269
|
+
env: process.env,
|
|
1270
|
+
autoRotate: false,
|
|
1271
|
+
});
|
|
1272
|
+
} catch {
|
|
1273
|
+
return {
|
|
1274
|
+
ok: false,
|
|
1275
|
+
reason: "no_session",
|
|
1276
|
+
cursor: normalizedSince,
|
|
1277
|
+
eventCount: 0,
|
|
1278
|
+
errorCount: 0,
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
if (!session || !session.token) {
|
|
1282
|
+
return {
|
|
1283
|
+
ok: false,
|
|
1284
|
+
reason: "not_authenticated",
|
|
1285
|
+
cursor: normalizedSince,
|
|
1286
|
+
eventCount: 0,
|
|
1287
|
+
errorCount: 0,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1292
|
+
const query = new URLSearchParams();
|
|
1293
|
+
if (normalizedSince) {
|
|
1294
|
+
query.set("after", normalizedSince);
|
|
1295
|
+
}
|
|
1296
|
+
const suffix = query.toString() ? `?${query.toString()}` : "";
|
|
1297
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/stream${suffix}`;
|
|
1298
|
+
const controller = new AbortController();
|
|
1299
|
+
const normalizedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
|
|
1300
|
+
const timeoutHandle = setTimeout(() => controller.abort(), normalizedTimeoutMs);
|
|
1301
|
+
if (typeof timeoutHandle.unref === "function") {
|
|
1302
|
+
timeoutHandle.unref();
|
|
1303
|
+
}
|
|
1304
|
+
const forwardAbort = () => controller.abort(signal?.reason);
|
|
1305
|
+
if (signal) {
|
|
1306
|
+
if (signal.aborted) {
|
|
1307
|
+
controller.abort(signal.reason);
|
|
1308
|
+
} else {
|
|
1309
|
+
signal.addEventListener("abort", forwardAbort, { once: true });
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
let response;
|
|
1314
|
+
try {
|
|
1315
|
+
response = await fetchImpl(
|
|
1316
|
+
endpoint,
|
|
1317
|
+
{
|
|
1318
|
+
method: "GET",
|
|
1319
|
+
headers: {
|
|
1320
|
+
Accept: "text/event-stream",
|
|
1321
|
+
Authorization: `Bearer ${session.token}`,
|
|
1322
|
+
},
|
|
1323
|
+
signal: controller.signal,
|
|
1324
|
+
},
|
|
1325
|
+
normalizedTimeoutMs
|
|
1326
|
+
);
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
clearTimeout(timeoutHandle);
|
|
1329
|
+
if (signal) signal.removeEventListener("abort", forwardAbort);
|
|
1330
|
+
return {
|
|
1331
|
+
ok: false,
|
|
1332
|
+
reason: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
|
|
1333
|
+
cursor: normalizedSince,
|
|
1334
|
+
eventCount: 0,
|
|
1335
|
+
errorCount: 0,
|
|
1336
|
+
aborted: Boolean(signal?.aborted || isAbortLike(error)),
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
clearTimeout(timeoutHandle);
|
|
1340
|
+
|
|
1341
|
+
let cursor = normalizedSince;
|
|
1342
|
+
let eventCount = 0;
|
|
1343
|
+
let errorCount = 0;
|
|
1344
|
+
let lastErrorReason = "";
|
|
1345
|
+
const decoder = new TextDecoder();
|
|
1346
|
+
let buffer = "";
|
|
1347
|
+
|
|
1348
|
+
const handlers = {
|
|
1349
|
+
cursor: () => cursor,
|
|
1350
|
+
onHeartbeat,
|
|
1351
|
+
onError: async (payload) => {
|
|
1352
|
+
errorCount += 1;
|
|
1353
|
+
lastErrorReason = normalizeString(payload?.reason) || "session_stream_error";
|
|
1354
|
+
await onError(payload);
|
|
1355
|
+
},
|
|
1356
|
+
onEvent: async (event) => {
|
|
1357
|
+
const eventCursor = normalizeString(event?.cursor);
|
|
1358
|
+
if (eventCursor) cursor = eventCursor;
|
|
1359
|
+
eventCount += 1;
|
|
1360
|
+
await onEvent(event);
|
|
1361
|
+
},
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
try {
|
|
1365
|
+
if (!response || !response.ok || !response.body) {
|
|
1366
|
+
return {
|
|
1367
|
+
ok: false,
|
|
1368
|
+
reason: `api_${response ? response.status : "no_response"}`,
|
|
1369
|
+
cursor,
|
|
1370
|
+
eventCount,
|
|
1371
|
+
errorCount,
|
|
1372
|
+
status: response?.status,
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
for await (const chunk of readResponseTextChunks(response)) {
|
|
1377
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
1378
|
+
buffer = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1379
|
+
const blocks = buffer.split("\n\n");
|
|
1380
|
+
buffer = blocks.pop() || "";
|
|
1381
|
+
for (const block of blocks) {
|
|
1382
|
+
await processSseBlock(block, handlers);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
buffer += decoder.decode();
|
|
1386
|
+
if (normalizeString(buffer)) {
|
|
1387
|
+
await processSseBlock(buffer, handlers);
|
|
1388
|
+
}
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
return {
|
|
1391
|
+
ok: false,
|
|
1392
|
+
reason: isAbortLike(error) || signal?.aborted ? "aborted" : normalizeString(error?.message) || "stream_failed",
|
|
1393
|
+
cursor,
|
|
1394
|
+
eventCount,
|
|
1395
|
+
errorCount,
|
|
1396
|
+
aborted: Boolean(signal?.aborted || isAbortLike(error)),
|
|
1397
|
+
};
|
|
1398
|
+
} finally {
|
|
1399
|
+
if (signal) signal.removeEventListener("abort", forwardAbort);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
return {
|
|
1403
|
+
ok: !lastErrorReason,
|
|
1404
|
+
reason: lastErrorReason,
|
|
1405
|
+
cursor,
|
|
1406
|
+
eventCount,
|
|
1407
|
+
errorCount,
|
|
1408
|
+
aborted: Boolean(signal?.aborted),
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1134
1412
|
/**
|
|
1135
1413
|
* Poll the latest durable session events page via the reverse-history endpoint.
|
|
1136
1414
|
*
|
|
@@ -1365,6 +1643,123 @@ export async function listSessionMessageActions(
|
|
|
1365
1643
|
}
|
|
1366
1644
|
}
|
|
1367
1645
|
|
|
1646
|
+
function pinnedEventContentText(event = {}) {
|
|
1647
|
+
const payload = event && typeof event === "object" ? event.payload || {} : {};
|
|
1648
|
+
return (
|
|
1649
|
+
normalizeString(payload.note) ||
|
|
1650
|
+
normalizeString(payload.message) ||
|
|
1651
|
+
normalizeString(payload.text) ||
|
|
1652
|
+
normalizeString(payload.content) ||
|
|
1653
|
+
normalizeString(payload.summary) ||
|
|
1654
|
+
""
|
|
1655
|
+
);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
function pinnedEventAuthorId(event = {}) {
|
|
1659
|
+
const agent = event && typeof event === "object" ? event.agent || {} : {};
|
|
1660
|
+
return normalizeString(agent.id || agent.agentId) || normalizeString(event.agentId) || "";
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
/**
|
|
1664
|
+
* Resolve the session's active pinned messages, enriched with each pinned
|
|
1665
|
+
* message's author and content so a CLI agent can actually read them (not just
|
|
1666
|
+
* see sequence numbers). Pins come from the action projection
|
|
1667
|
+
* (`projection.pinnedMessages`, capped at `projection.pinLimit`); content is
|
|
1668
|
+
* resolved per pinned sequence via `/events/before`. Bounded by the pin cap
|
|
1669
|
+
* (<= 10), so at most ~10 single-event lookups.
|
|
1670
|
+
*/
|
|
1671
|
+
export async function fetchSessionPinnedMessages(
|
|
1672
|
+
sessionId,
|
|
1673
|
+
{
|
|
1674
|
+
targetPath = process.cwd(),
|
|
1675
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1676
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1677
|
+
fetchImpl = fetchWithTimeout,
|
|
1678
|
+
nowMs = Date.now,
|
|
1679
|
+
listActions = listSessionMessageActions,
|
|
1680
|
+
fetchEventsBefore = pollSessionEventsBefore,
|
|
1681
|
+
} = {}
|
|
1682
|
+
) {
|
|
1683
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1684
|
+
if (!normalizedSessionId) {
|
|
1685
|
+
return { ok: false, reason: "invalid_session_id", pins: [], pinLimit: 0, count: 0 };
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
const actionsResult = await listActions(normalizedSessionId, {
|
|
1689
|
+
targetPath,
|
|
1690
|
+
timeoutMs,
|
|
1691
|
+
resolveAuthSession,
|
|
1692
|
+
fetchImpl,
|
|
1693
|
+
nowMs,
|
|
1694
|
+
});
|
|
1695
|
+
if (!actionsResult || !actionsResult.ok) {
|
|
1696
|
+
return {
|
|
1697
|
+
ok: false,
|
|
1698
|
+
reason: normalizeString(actionsResult?.reason) || "actions_read_failed",
|
|
1699
|
+
pins: [],
|
|
1700
|
+
pinLimit: 0,
|
|
1701
|
+
count: 0,
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
const projection = actionsResult.projection && typeof actionsResult.projection === "object"
|
|
1706
|
+
? actionsResult.projection
|
|
1707
|
+
: {};
|
|
1708
|
+
const pinLimit = Number(projection.pinLimit) || 0;
|
|
1709
|
+
const pinnedActions = Array.isArray(projection.pinnedMessages) ? projection.pinnedMessages : [];
|
|
1710
|
+
|
|
1711
|
+
const pins = await Promise.all(
|
|
1712
|
+
pinnedActions.map(async (action) => {
|
|
1713
|
+
const targetSequenceId = Number(action?.targetSequenceId) || 0;
|
|
1714
|
+
const base = {
|
|
1715
|
+
targetSequenceId,
|
|
1716
|
+
targetCursor: normalizeString(action?.targetCursor) || null,
|
|
1717
|
+
pinnedBy: normalizeString(action?.actorId) || "",
|
|
1718
|
+
pinnedByKind: normalizeString(action?.actorKind) || "",
|
|
1719
|
+
pinnedAt: normalizeString(action?.createdAt) || "",
|
|
1720
|
+
author: "",
|
|
1721
|
+
content: "",
|
|
1722
|
+
resolved: false,
|
|
1723
|
+
};
|
|
1724
|
+
if (targetSequenceId <= 0) {
|
|
1725
|
+
return base;
|
|
1726
|
+
}
|
|
1727
|
+
const eventsResult = await fetchEventsBefore(normalizedSessionId, {
|
|
1728
|
+
targetPath,
|
|
1729
|
+
beforeSequence: targetSequenceId + 1,
|
|
1730
|
+
limit: 1,
|
|
1731
|
+
timeoutMs,
|
|
1732
|
+
resolveAuthSession,
|
|
1733
|
+
fetchImpl,
|
|
1734
|
+
nowMs,
|
|
1735
|
+
});
|
|
1736
|
+
if (eventsResult && eventsResult.ok && Array.isArray(eventsResult.events)) {
|
|
1737
|
+
const match =
|
|
1738
|
+
eventsResult.events.find(
|
|
1739
|
+
(event) => Number(event?.sequenceId) === targetSequenceId,
|
|
1740
|
+
) ||
|
|
1741
|
+
eventsResult.events[eventsResult.events.length - 1] ||
|
|
1742
|
+
null;
|
|
1743
|
+
if (match) {
|
|
1744
|
+
base.author = pinnedEventAuthorId(match);
|
|
1745
|
+
base.content = pinnedEventContentText(match);
|
|
1746
|
+
base.resolved = true;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
return base;
|
|
1750
|
+
}),
|
|
1751
|
+
);
|
|
1752
|
+
|
|
1753
|
+
return {
|
|
1754
|
+
ok: true,
|
|
1755
|
+
reason: "",
|
|
1756
|
+
sessionId: normalizedSessionId,
|
|
1757
|
+
pins,
|
|
1758
|
+
pinLimit,
|
|
1759
|
+
count: pins.length,
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1368
1763
|
export async function createSessionMessageAction(
|
|
1369
1764
|
sessionId,
|
|
1370
1765
|
{
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* Adds at render time:
|
|
8
8
|
* - Per-agent active duration (first → last event with that agent id)
|
|
9
9
|
* - Total session live-for (createdAt → last event)
|
|
10
|
-
* - Token + cost roll-up
|
|
10
|
+
* - Token + cost roll-up from session_usage events through the
|
|
11
|
+
* pricing ledger, including idempotency dedupe
|
|
11
12
|
* - Avatar per speaker, picked from PERSONA_VISUALS / CLIENT_FAMILY_AVATARS,
|
|
12
13
|
* or a deterministic letter-tile fallback
|
|
13
14
|
* - Senti-orchestrator events tagged with the orchestrator avatar so
|
|
@@ -18,6 +19,7 @@
|
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import { PERSONA_VISUALS, ORCHESTRATOR_VISUALS } from "../agents/persona-visuals.js";
|
|
22
|
+
import { buildSessionUsageLedger } from "./pricing-ledger.js";
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Avatar map for client families (the OUTSIDE-the-persona-set agents
|
|
@@ -232,45 +234,39 @@ function eventBody(event) {
|
|
|
232
234
|
* Compute deterministic activity stats from the event log:
|
|
233
235
|
* - sessionLiveSeconds: created → last event
|
|
234
236
|
* - perAgent[agentId]: { firstSeen, lastSeen, eventCount, activeSeconds, family, displayName, model }
|
|
235
|
-
* - totals: { tokenTotal, costTotalUsd } summed
|
|
237
|
+
* - totals: { tokenTotal, costTotalUsd } summed through the pricing ledger
|
|
236
238
|
* - sentiActions: count of orchestrator events
|
|
237
239
|
*/
|
|
238
240
|
export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerProfiles = new Map() } = {}) {
|
|
239
241
|
const perAgent = new Map();
|
|
240
242
|
let firstEventTs = null;
|
|
241
243
|
let lastEventTs = null;
|
|
242
|
-
let tokenTotal = 0;
|
|
243
|
-
let costTotalUsd = 0;
|
|
244
244
|
let sentiActions = 0;
|
|
245
245
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (!
|
|
256
|
-
|
|
257
|
-
if (lowerId === "senti" || lowerId === "kai-chen") sentiActions += 1;
|
|
258
|
-
|
|
259
|
-
if (!perAgent.has(agentId)) {
|
|
260
|
-
const profile = speakerProfiles.get(agentId) || null;
|
|
246
|
+
const ensureAgentRecord = ({
|
|
247
|
+
agentId,
|
|
248
|
+
agentModel = "",
|
|
249
|
+
epoch,
|
|
250
|
+
} = {}) => {
|
|
251
|
+
const normalizedAgentId = normalize(agentId);
|
|
252
|
+
if (!normalizedAgentId || !Number.isFinite(epoch)) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
if (!perAgent.has(normalizedAgentId)) {
|
|
256
|
+
const profile = speakerProfiles.get(normalizedAgentId) || null;
|
|
261
257
|
const identity = resolveSpeakerIdentity({
|
|
262
|
-
agentId,
|
|
263
|
-
agentModel
|
|
258
|
+
agentId: normalizedAgentId,
|
|
259
|
+
agentModel,
|
|
264
260
|
profile,
|
|
265
261
|
});
|
|
266
|
-
perAgent.set(
|
|
267
|
-
agentId,
|
|
262
|
+
perAgent.set(normalizedAgentId, {
|
|
263
|
+
agentId: normalizedAgentId,
|
|
268
264
|
family: identity.family,
|
|
269
265
|
displayName: identity.displayName,
|
|
270
266
|
avatar: identity.avatar,
|
|
271
267
|
avatarUrl: identity.avatarUrl,
|
|
272
268
|
color: identity.color,
|
|
273
|
-
model:
|
|
269
|
+
model: agentModel,
|
|
274
270
|
firstSeenMs: epoch,
|
|
275
271
|
lastSeenMs: epoch,
|
|
276
272
|
eventCount: 0,
|
|
@@ -278,21 +274,59 @@ export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerP
|
|
|
278
274
|
costUsd: 0,
|
|
279
275
|
});
|
|
280
276
|
}
|
|
281
|
-
const record = perAgent.get(
|
|
277
|
+
const record = perAgent.get(normalizedAgentId);
|
|
278
|
+
if (!record.model && agentModel) {
|
|
279
|
+
record.model = agentModel;
|
|
280
|
+
}
|
|
281
|
+
return record;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
for (const event of events) {
|
|
285
|
+
const ts = eventTimestamp(event);
|
|
286
|
+
if (!ts) continue;
|
|
287
|
+
const epoch = Date.parse(ts);
|
|
288
|
+
if (!Number.isFinite(epoch)) continue;
|
|
289
|
+
if (firstEventTs == null || epoch < firstEventTs) firstEventTs = epoch;
|
|
290
|
+
if (lastEventTs == null || epoch > lastEventTs) lastEventTs = epoch;
|
|
291
|
+
|
|
292
|
+
const agentId = normalize(event.agent?.id || event.agentId);
|
|
293
|
+
if (!agentId) continue;
|
|
294
|
+
const lowerId = agentId.toLowerCase();
|
|
295
|
+
if (lowerId === "senti" || lowerId === "kai-chen") sentiActions += 1;
|
|
296
|
+
|
|
297
|
+
const record = ensureAgentRecord({
|
|
298
|
+
agentId,
|
|
299
|
+
agentModel: event.agent?.model || event.agentModel || "",
|
|
300
|
+
epoch,
|
|
301
|
+
});
|
|
302
|
+
if (!record) continue;
|
|
282
303
|
record.eventCount += 1;
|
|
283
304
|
if (epoch < record.firstSeenMs) record.firstSeenMs = epoch;
|
|
284
305
|
if (epoch > record.lastSeenMs) record.lastSeenMs = epoch;
|
|
306
|
+
}
|
|
285
307
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
308
|
+
const usageLedger = buildSessionUsageLedger(events, {
|
|
309
|
+
sessionId: normalize(sessionMeta.sessionId),
|
|
310
|
+
});
|
|
311
|
+
const fallbackUsageEpoch =
|
|
312
|
+
lastEventTs ??
|
|
313
|
+
firstEventTs ??
|
|
314
|
+
(Number.isFinite(Date.parse(sessionMeta?.createdAt)) ? Date.parse(sessionMeta.createdAt) : 0);
|
|
315
|
+
for (const entry of usageLedger.entries) {
|
|
316
|
+
const entryEpoch = Number.isFinite(Date.parse(entry.timestamp))
|
|
317
|
+
? Date.parse(entry.timestamp)
|
|
318
|
+
: fallbackUsageEpoch;
|
|
319
|
+
const record = ensureAgentRecord({
|
|
320
|
+
agentId: entry.agentId,
|
|
321
|
+
agentModel: entry.model,
|
|
322
|
+
epoch: entryEpoch,
|
|
323
|
+
});
|
|
324
|
+
if (!record) continue;
|
|
325
|
+
if ((!record.model || record.model === "unknown") && entry.model && entry.model !== "unknown") {
|
|
326
|
+
record.model = entry.model;
|
|
295
327
|
}
|
|
328
|
+
record.tokens += entry.totalTokens;
|
|
329
|
+
record.costUsd = Math.round((record.costUsd + entry.providerCostUsd) * 1_000_000) / 1_000_000;
|
|
296
330
|
}
|
|
297
331
|
|
|
298
332
|
const createdAtMs = sessionMeta?.createdAt
|
|
@@ -329,7 +363,19 @@ export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerP
|
|
|
329
363
|
endedAt: lastEventTs ? new Date(lastEventTs).toISOString() : null,
|
|
330
364
|
sessionLiveSeconds,
|
|
331
365
|
agents,
|
|
332
|
-
totals: {
|
|
366
|
+
totals: {
|
|
367
|
+
tokenTotal: usageLedger.totals.totalTokens,
|
|
368
|
+
inputTokens: usageLedger.totals.inputTokens,
|
|
369
|
+
outputTokens: usageLedger.totals.outputTokens,
|
|
370
|
+
costTotalUsd: usageLedger.totals.providerCostUsd,
|
|
371
|
+
customerCostTotalUsd: usageLedger.totals.hasCustomerCost
|
|
372
|
+
? usageLedger.totals.customerCostUsd
|
|
373
|
+
: null,
|
|
374
|
+
usageEntries: usageLedger.entries.length,
|
|
375
|
+
duplicatesSkipped: usageLedger.duplicatesSkipped,
|
|
376
|
+
unpriced: usageLedger.totals.unpriced,
|
|
377
|
+
priceBookVersions: usageLedger.priceBookVersions,
|
|
378
|
+
},
|
|
333
379
|
sentiActions,
|
|
334
380
|
};
|
|
335
381
|
}
|
|
@@ -396,8 +442,12 @@ export function buildTranscriptMarkdown({
|
|
|
396
442
|
lines.push(`Live for: ${formatDuration(stats.sessionLiveSeconds)}`);
|
|
397
443
|
lines.push(`Senti actions: ${stats.sentiActions}`);
|
|
398
444
|
if (stats.totals.tokenTotal > 0 || stats.totals.costTotalUsd > 0) {
|
|
445
|
+
const billableText =
|
|
446
|
+
stats.totals.customerCostTotalUsd == null
|
|
447
|
+
? ""
|
|
448
|
+
: ` · Billable: $${stats.totals.customerCostTotalUsd.toFixed(4)}`;
|
|
399
449
|
lines.push(
|
|
400
|
-
`Tokens: ${stats.totals.tokenTotal.toLocaleString("en-US")} · Cost: $${stats.totals.costTotalUsd.toFixed(4)}`,
|
|
450
|
+
`Tokens: ${stats.totals.tokenTotal.toLocaleString("en-US")} · Cost: $${stats.totals.costTotalUsd.toFixed(4)}${billableText}`,
|
|
401
451
|
);
|
|
402
452
|
}
|
|
403
453
|
lines.push("");
|
package/src/session/usage.js
CHANGED
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
* }
|
|
40
40
|
*
|
|
41
41
|
* Design choice: emit BOTH the convenient flat fields AND a
|
|
42
|
-
* `payload.usage` block
|
|
43
|
-
*
|
|
44
|
-
* fields directly without re-parsing.
|
|
42
|
+
* `payload.usage` block. Transcript/download totals flow through
|
|
43
|
+
* pricing-ledger idempotency semantics, while web UIs can display the
|
|
44
|
+
* structured fields directly without re-parsing.
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
47
|
import process from "node:process";
|
|
@@ -191,8 +191,8 @@ export async function emitLLMInteraction(
|
|
|
191
191
|
chars: responseText.length,
|
|
192
192
|
text: responseText || undefined,
|
|
193
193
|
},
|
|
194
|
-
// Mirror into payload.usage
|
|
195
|
-
//
|
|
194
|
+
// Mirror into payload.usage for legacy readers and telemetry sync;
|
|
195
|
+
// transcript/download totals use the canonical pricing-ledger fields.
|
|
196
196
|
usage: {
|
|
197
197
|
totalTokens: totalT,
|
|
198
198
|
costUsd: cost,
|