sentinelayer-cli 0.8.7 → 0.8.9
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/session.js +226 -6
- package/src/session/agent-registry.js +60 -1
- package/src/session/senti-naming.js +180 -0
- package/src/session/sync.js +155 -0
- package/src/session/transcript.js +429 -0
- package/src/session/usage.js +213 -0
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -50,7 +50,11 @@ import {
|
|
|
50
50
|
} from "../session/store.js";
|
|
51
51
|
import { appendToStream, readStream, tailStream } from "../session/stream.js";
|
|
52
52
|
import { readSessionPreview } from "../session/preview.js";
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
listSessionsFromApi,
|
|
55
|
+
probeSessionAccess,
|
|
56
|
+
syncSessionMetadataToApi,
|
|
57
|
+
} from "../session/sync.js";
|
|
54
58
|
import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
|
|
55
59
|
import { mergeLiveSources } from "../session/live-source.js";
|
|
56
60
|
import {
|
|
@@ -604,6 +608,16 @@ export function registerSessionCommand(program) {
|
|
|
604
608
|
since: sinceArg,
|
|
605
609
|
});
|
|
606
610
|
|
|
611
|
+
// Discriminate "owned-but-no-human-messages" from "not a member /
|
|
612
|
+
// wrong session id". The hydrate path returns ok:true with
|
|
613
|
+
// relayed=0 + cursor=null in both cases, which Carter just hit
|
|
614
|
+
// on session d34f03ba — the user couldn't tell whether they
|
|
615
|
+
// typed the wrong id or it was just genuinely empty.
|
|
616
|
+
let access = null;
|
|
617
|
+
if (result.ok && result.relayed === 0 && !result.cursor) {
|
|
618
|
+
access = await probeSessionAccess(normalizedSessionId, { targetPath });
|
|
619
|
+
}
|
|
620
|
+
|
|
607
621
|
const payload = {
|
|
608
622
|
command: "session sync",
|
|
609
623
|
targetPath,
|
|
@@ -614,6 +628,7 @@ export function registerSessionCommand(program) {
|
|
|
614
628
|
dropped: result.dropped,
|
|
615
629
|
cursor: result.cursor,
|
|
616
630
|
persistedCursor: result.persistedCursor,
|
|
631
|
+
access: access || undefined,
|
|
617
632
|
};
|
|
618
633
|
if (shouldEmitJson(options, command)) {
|
|
619
634
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -623,6 +638,27 @@ export function registerSessionCommand(program) {
|
|
|
623
638
|
console.log(
|
|
624
639
|
`Hydrated session ${normalizedSessionId}: relayed=${result.relayed} dropped=${result.dropped}.`,
|
|
625
640
|
);
|
|
641
|
+
if (access && !access.accessible) {
|
|
642
|
+
if (access.reason === "session_not_found") {
|
|
643
|
+
console.log(
|
|
644
|
+
pc.yellow(
|
|
645
|
+
`Heads up: that session id isn't in your account. Verify with \`sl session list --remote\`.`,
|
|
646
|
+
),
|
|
647
|
+
);
|
|
648
|
+
} else if (access.reason === "not_a_member") {
|
|
649
|
+
console.log(
|
|
650
|
+
pc.yellow(
|
|
651
|
+
`Heads up: you aren't a member of session ${normalizedSessionId} — sync silently no-ops. Ask the owner to add you, or list your own with \`sl session list --remote\`.`,
|
|
652
|
+
),
|
|
653
|
+
);
|
|
654
|
+
} else if (access.reason !== "" && access.reason !== "no_session") {
|
|
655
|
+
console.log(
|
|
656
|
+
pc.gray(
|
|
657
|
+
`(probe: ${access.reason}; if you expected messages, check \`sl session list --remote\`.)`,
|
|
658
|
+
),
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
626
662
|
} else {
|
|
627
663
|
console.log(
|
|
628
664
|
pc.yellow(
|
|
@@ -802,6 +838,122 @@ export function registerSessionCommand(program) {
|
|
|
802
838
|
}
|
|
803
839
|
});
|
|
804
840
|
|
|
841
|
+
session
|
|
842
|
+
.command("download <sessionId>")
|
|
843
|
+
.description(
|
|
844
|
+
"Download an iMessage-style Markdown transcript: deterministic timestamps, per-agent active duration, known persona/orchestrator/family avatars, and human avatars from your auth profile",
|
|
845
|
+
)
|
|
846
|
+
.option("--out <file>", "Output path (default: <sessionId>.md in cwd)")
|
|
847
|
+
.option(
|
|
848
|
+
"--no-system-events",
|
|
849
|
+
"Suppress join/leave/identified/daemon-alert lines (keeps only user + agent messages)",
|
|
850
|
+
)
|
|
851
|
+
.option(
|
|
852
|
+
"--remote",
|
|
853
|
+
"Hydrate from the SentinelLayer API before rendering (pulls web-posted messages into the local NDJSON)",
|
|
854
|
+
)
|
|
855
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
856
|
+
.option("--json", "Emit machine-readable output")
|
|
857
|
+
.action(async (sessionId, options, command) => {
|
|
858
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
859
|
+
if (!normalizedSessionId) {
|
|
860
|
+
throw new Error("session id is required.");
|
|
861
|
+
}
|
|
862
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
863
|
+
const emitJson = shouldEmitJson(options, command);
|
|
864
|
+
|
|
865
|
+
let hydration = null;
|
|
866
|
+
if (options.remote) {
|
|
867
|
+
hydration = await hydrateSessionFromRemote({
|
|
868
|
+
sessionId: normalizedSessionId,
|
|
869
|
+
targetPath,
|
|
870
|
+
}).catch((error) => ({ ok: false, reason: error?.message || "hydrate_failed" }));
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const sessionPayload = await getSession(normalizedSessionId, { targetPath });
|
|
874
|
+
if (!sessionPayload) {
|
|
875
|
+
throw new Error(`Session '${normalizedSessionId}' was not found.`);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const [agents, events] = await Promise.all([
|
|
879
|
+
listAgents(normalizedSessionId, { targetPath, includeInactive: true }),
|
|
880
|
+
readStream(normalizedSessionId, { targetPath, tail: 0 }),
|
|
881
|
+
]);
|
|
882
|
+
|
|
883
|
+
// Pull GitHub/Google avatar + display name from the active auth
|
|
884
|
+
// session so any human-id seen in the stream renders with the
|
|
885
|
+
// user's real photo instead of the generic 🧑 fallback.
|
|
886
|
+
const speakerProfiles = new Map();
|
|
887
|
+
const auth = await resolveActiveAuthSession({
|
|
888
|
+
cwd: targetPath,
|
|
889
|
+
env: process.env,
|
|
890
|
+
autoRotate: false,
|
|
891
|
+
}).catch(() => null);
|
|
892
|
+
const userAvatarUrl = normalizeString(auth?.user?.avatarUrl);
|
|
893
|
+
const userDisplay =
|
|
894
|
+
normalizeString(auth?.user?.githubUsername) ||
|
|
895
|
+
normalizeString(auth?.user?.email);
|
|
896
|
+
if (userAvatarUrl || userDisplay) {
|
|
897
|
+
const profile = {
|
|
898
|
+
displayName: userDisplay || "You",
|
|
899
|
+
avatarUrl: userAvatarUrl || null,
|
|
900
|
+
family: "human",
|
|
901
|
+
};
|
|
902
|
+
for (const id of ["cli-user", "human", "you", "user"]) {
|
|
903
|
+
speakerProfiles.set(id, profile);
|
|
904
|
+
}
|
|
905
|
+
if (userDisplay) speakerProfiles.set(userDisplay, profile);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const { buildTranscriptMarkdown } = await import(
|
|
909
|
+
"../session/transcript.js"
|
|
910
|
+
);
|
|
911
|
+
const { markdown, stats } = buildTranscriptMarkdown({
|
|
912
|
+
sessionMeta: {
|
|
913
|
+
sessionId: normalizedSessionId,
|
|
914
|
+
createdAt: sessionPayload.createdAt,
|
|
915
|
+
status: sessionPayload.status,
|
|
916
|
+
},
|
|
917
|
+
events,
|
|
918
|
+
agents,
|
|
919
|
+
speakerProfiles,
|
|
920
|
+
options: {
|
|
921
|
+
// commander maps --no-system-events to systemEvents: false
|
|
922
|
+
includeSystemEvents: options.systemEvents !== false,
|
|
923
|
+
},
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
const outArg = normalizeString(options.out);
|
|
927
|
+
const outPath = outArg
|
|
928
|
+
? path.resolve(process.cwd(), outArg)
|
|
929
|
+
: path.resolve(process.cwd(), `${normalizedSessionId}.md`);
|
|
930
|
+
await fsp.mkdir(path.dirname(outPath), { recursive: true });
|
|
931
|
+
await fsp.writeFile(outPath, markdown, "utf-8");
|
|
932
|
+
|
|
933
|
+
const payload = {
|
|
934
|
+
command: "session download",
|
|
935
|
+
sessionId: normalizedSessionId,
|
|
936
|
+
outPath,
|
|
937
|
+
bytes: Buffer.byteLength(markdown, "utf-8"),
|
|
938
|
+
eventCount: events.length,
|
|
939
|
+
agentCount: agents.length,
|
|
940
|
+
sessionLiveSeconds: stats.sessionLiveSeconds,
|
|
941
|
+
sentiActions: stats.sentiActions,
|
|
942
|
+
totals: stats.totals,
|
|
943
|
+
remote: hydration,
|
|
944
|
+
};
|
|
945
|
+
if (emitJson) {
|
|
946
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
console.log(pc.bold(`Downloaded session ${normalizedSessionId} → ${outPath}`));
|
|
950
|
+
console.log(
|
|
951
|
+
pc.gray(
|
|
952
|
+
`${events.length} events · ${agents.length} agents · live ${stats.sessionLiveSeconds}s · senti=${stats.sentiActions} · tokens=${stats.totals.tokenTotal} · cost=$${stats.totals.costTotalUsd.toFixed(4)}`,
|
|
953
|
+
),
|
|
954
|
+
);
|
|
955
|
+
});
|
|
956
|
+
|
|
805
957
|
session
|
|
806
958
|
.command("leave <sessionId>")
|
|
807
959
|
.description("Leave a session")
|
|
@@ -838,7 +990,13 @@ export function registerSessionCommand(program) {
|
|
|
838
990
|
|
|
839
991
|
session
|
|
840
992
|
.command("list")
|
|
841
|
-
.description(
|
|
993
|
+
.description(
|
|
994
|
+
"List sessions. Defaults to local cache; pass --remote to query the SentinelLayer API for every session on your account.",
|
|
995
|
+
)
|
|
996
|
+
.option(
|
|
997
|
+
"--remote",
|
|
998
|
+
"Query the API for sessions on the authenticated account (covers sessions created from any workspace or the web dashboard)",
|
|
999
|
+
)
|
|
842
1000
|
.option(
|
|
843
1001
|
"--include-archived",
|
|
844
1002
|
"Include archived/expired sessions (past conversations)",
|
|
@@ -854,18 +1012,80 @@ export function registerSessionCommand(program) {
|
|
|
854
1012
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
855
1013
|
const includeArchived = Boolean(options.includeArchived);
|
|
856
1014
|
const limit = parsePositiveInteger(options.limit, "limit", 50);
|
|
1015
|
+
const emitJson = shouldEmitJson(options, command);
|
|
1016
|
+
|
|
1017
|
+
if (options.remote) {
|
|
1018
|
+
const remote = await listSessionsFromApi({
|
|
1019
|
+
targetPath,
|
|
1020
|
+
includeArchived,
|
|
1021
|
+
limit,
|
|
1022
|
+
});
|
|
1023
|
+
const trimmed = emitJson ? remote.sessions : remote.sessions.slice(0, limit);
|
|
1024
|
+
const payload = {
|
|
1025
|
+
command: "session list",
|
|
1026
|
+
source: "remote",
|
|
1027
|
+
targetPath,
|
|
1028
|
+
includeArchived,
|
|
1029
|
+
ok: remote.ok,
|
|
1030
|
+
reason: remote.reason || "",
|
|
1031
|
+
count: remote.count,
|
|
1032
|
+
sessions: trimmed,
|
|
1033
|
+
};
|
|
1034
|
+
if (emitJson) {
|
|
1035
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
if (!remote.ok) {
|
|
1039
|
+
console.log(
|
|
1040
|
+
pc.yellow(
|
|
1041
|
+
`Remote list unavailable (${remote.reason}). Try \`sl auth login\` or run without --remote for local cache.`,
|
|
1042
|
+
),
|
|
1043
|
+
);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
if (remote.sessions.length === 0) {
|
|
1047
|
+
console.log(
|
|
1048
|
+
pc.yellow(
|
|
1049
|
+
includeArchived
|
|
1050
|
+
? "No sessions on your account."
|
|
1051
|
+
: "No active sessions on your account. Re-run with --include-archived to see history.",
|
|
1052
|
+
),
|
|
1053
|
+
);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
for (const item of trimmed) {
|
|
1057
|
+
const archive = item.archiveStatus ? ` archive=${item.archiveStatus}` : "";
|
|
1058
|
+
const created = item.createdAt || "?";
|
|
1059
|
+
const lastActivity = item.lastActivityAt
|
|
1060
|
+
? ` last=${item.lastActivityAt}`
|
|
1061
|
+
: "";
|
|
1062
|
+
console.log(
|
|
1063
|
+
`${item.sessionId} status=${item.status}${archive} created=${created}${lastActivity}`,
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
if (remote.count > trimmed.length) {
|
|
1067
|
+
console.log(
|
|
1068
|
+
pc.gray(
|
|
1069
|
+
`… ${remote.count - trimmed.length} more (raise --limit or use --json).`,
|
|
1070
|
+
),
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
857
1076
|
const sessions = includeArchived
|
|
858
1077
|
? await listAllSessions({ targetPath })
|
|
859
1078
|
: await listActiveSessions({ targetPath });
|
|
860
|
-
const trimmed =
|
|
1079
|
+
const trimmed = emitJson ? sessions : sessions.slice(0, limit);
|
|
861
1080
|
const payload = {
|
|
862
1081
|
command: "session list",
|
|
1082
|
+
source: "local",
|
|
863
1083
|
targetPath,
|
|
864
1084
|
includeArchived,
|
|
865
1085
|
count: sessions.length,
|
|
866
1086
|
sessions: trimmed,
|
|
867
1087
|
};
|
|
868
|
-
if (
|
|
1088
|
+
if (emitJson) {
|
|
869
1089
|
console.log(JSON.stringify(payload, null, 2));
|
|
870
1090
|
return;
|
|
871
1091
|
}
|
|
@@ -873,8 +1093,8 @@ export function registerSessionCommand(program) {
|
|
|
873
1093
|
console.log(
|
|
874
1094
|
pc.yellow(
|
|
875
1095
|
includeArchived
|
|
876
|
-
? "No sessions in cache."
|
|
877
|
-
: "No active sessions. Run with --
|
|
1096
|
+
? "No sessions in local cache. Run with --remote to fetch from the API."
|
|
1097
|
+
: "No active sessions in local cache. Run with --remote to see sessions from other workspaces or the web.",
|
|
878
1098
|
),
|
|
879
1099
|
);
|
|
880
1100
|
return;
|
|
@@ -7,6 +7,11 @@ import { STUCK_THRESHOLDS } from "../agents/jules/pulse.js";
|
|
|
7
7
|
import { createAgentEvent } from "../events/schema.js";
|
|
8
8
|
import { resolveSessionPaths } from "./paths.js";
|
|
9
9
|
import { emitContextBriefing } from "./recap.js";
|
|
10
|
+
import {
|
|
11
|
+
assignFriendlyName,
|
|
12
|
+
buildSentiWelcome,
|
|
13
|
+
shouldAutoRenameInRegistry,
|
|
14
|
+
} from "./senti-naming.js";
|
|
10
15
|
import { appendToStream } from "./stream.js";
|
|
11
16
|
|
|
12
17
|
const AGENT_SNAPSHOT_SCHEMA_VERSION = "1.0.0";
|
|
@@ -181,7 +186,26 @@ export async function registerAgent(
|
|
|
181
186
|
) {
|
|
182
187
|
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
183
188
|
const nowIso = new Date().toISOString();
|
|
184
|
-
const
|
|
189
|
+
const originalCallerAgentId = normalizeString(agentId);
|
|
190
|
+
let resolvedAgentId = originalCallerAgentId || generateAgentId(model);
|
|
191
|
+
let renamedFrom = "";
|
|
192
|
+
|
|
193
|
+
// Senti orchestrator hook: when the caller didn't supply an id, or
|
|
194
|
+
// supplied an explicit placeholder (`cli-user`, `agent-…`, `guest-…`),
|
|
195
|
+
// pick a friendly sequential name like `claude-3` / `codex-2` /
|
|
196
|
+
// `guest-1` so participants have a "face" in the transcript instead of
|
|
197
|
+
// a random hex blob. Caller-supplied real ids are NEVER renamed
|
|
198
|
+
// (kill tests like PR 348/351 register `codex-task-holder-1` with
|
|
199
|
+
// model="" and need the id to round-trip verbatim).
|
|
200
|
+
if (shouldAutoRenameInRegistry({ originalCallerAgentId })) {
|
|
201
|
+
const existingAgents = await listAgentsInternal(paths);
|
|
202
|
+
const friendly = assignFriendlyName({ model, existingAgents });
|
|
203
|
+
if (friendly && friendly !== resolvedAgentId.toLowerCase()) {
|
|
204
|
+
renamedFrom = resolvedAgentId;
|
|
205
|
+
resolvedAgentId = friendly;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
185
209
|
const snapshotPath = buildAgentSnapshotPath(paths, resolvedAgentId);
|
|
186
210
|
|
|
187
211
|
const snapshot = normalizeAgentSnapshot(
|
|
@@ -210,6 +234,21 @@ export async function registerAgent(
|
|
|
210
234
|
role: snapshot.role,
|
|
211
235
|
status: snapshot.status,
|
|
212
236
|
}, { targetPath });
|
|
237
|
+
|
|
238
|
+
if (renamedFrom) {
|
|
239
|
+
const welcome = buildSentiWelcome({
|
|
240
|
+
agentId: snapshot.agentId,
|
|
241
|
+
model: snapshot.model,
|
|
242
|
+
role: snapshot.role,
|
|
243
|
+
wasAnonymous: true,
|
|
244
|
+
originalAgentId: renamedFrom,
|
|
245
|
+
});
|
|
246
|
+
await emitAgentEvent(paths.sessionId, "agent_identified", {
|
|
247
|
+
...welcome,
|
|
248
|
+
sessionId: paths.sessionId,
|
|
249
|
+
}, { targetPath });
|
|
250
|
+
}
|
|
251
|
+
|
|
213
252
|
if (normalizeString(snapshot.agentId).toLowerCase() !== "senti") {
|
|
214
253
|
await emitContextBriefing(paths.sessionId, {
|
|
215
254
|
forAgentId: snapshot.agentId,
|
|
@@ -223,6 +262,26 @@ export async function registerAgent(
|
|
|
223
262
|
};
|
|
224
263
|
}
|
|
225
264
|
|
|
265
|
+
async function listAgentsInternal(paths) {
|
|
266
|
+
try {
|
|
267
|
+
const entries = await fsp.readdir(paths.agentsDir, { withFileTypes: true });
|
|
268
|
+
const out = [];
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
271
|
+
const raw = await readAgentSnapshot(path.join(paths.agentsDir, entry.name));
|
|
272
|
+
if (raw && typeof raw === "object" && raw.agentId) {
|
|
273
|
+
out.push({ agentId: raw.agentId, model: raw.model || "" });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
226
285
|
export async function heartbeatAgent(
|
|
227
286
|
sessionId,
|
|
228
287
|
agentId,
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Senti — auto-name + welcome anonymous participants.
|
|
3
|
+
*
|
|
4
|
+
* When an agent joins without a clear name + model, Senti steps in:
|
|
5
|
+
*
|
|
6
|
+
* 1. `assignFriendlyName({ model, existingAgents })` — generates a
|
|
7
|
+
* stable, human-readable id like "guest-3", "claude-2",
|
|
8
|
+
* "codex-anon-1" derived from the model family + the next free
|
|
9
|
+
* ordinal in the session. Sequential beats hex-suffix for the
|
|
10
|
+
* ChatGPT-style "everyone has a face" UX Carter asked for.
|
|
11
|
+
*
|
|
12
|
+
* 2. `buildSentiWelcome({ agentId, model, role })` — produces the
|
|
13
|
+
* payload for an `agent_identified` event Senti emits in the
|
|
14
|
+
* stream so the new participant + everyone watching sees the
|
|
15
|
+
* auto-assignment + how to override it.
|
|
16
|
+
*
|
|
17
|
+
* 3. `isAnonymousAgent({ agentId, model })` — single-source check
|
|
18
|
+
* for "this registration didn't carry real identity" used by
|
|
19
|
+
* callers to decide whether to invoke (1) and (2). Generic
|
|
20
|
+
* prefixes (`agent-…`, `cli-user`) and unknown models qualify.
|
|
21
|
+
*
|
|
22
|
+
* This module never touches the network or the disk; it's pure naming
|
|
23
|
+
* logic that the agent-registry wires into the registration path.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ANONYMOUS_AGENT_PREFIXES = Object.freeze(["agent-", "cli-user", "guest-"]);
|
|
27
|
+
const ANONYMOUS_MODELS = Object.freeze(["", "unknown", "cli", "anonymous"]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Strict: should the agent-registry auto-rename this registration?
|
|
31
|
+
*
|
|
32
|
+
* The hook's contract is "if a name is already there, leave it alone; if
|
|
33
|
+
* not, give them one." So we ONLY auto-rename when the caller gave us
|
|
34
|
+
* nothing OR the literal default placeholder `cli-user`. Any other
|
|
35
|
+
* caller-supplied id — even ones that *look* generic like `agent-alpha`,
|
|
36
|
+
* `guest-team`, or `codex-task-holder-1` — was an intentional choice and
|
|
37
|
+
* round-trips verbatim.
|
|
38
|
+
*
|
|
39
|
+
* Why so strict:
|
|
40
|
+
* - e2e test #91 (CLI session commands flow) does `session join
|
|
41
|
+
* --name agent-alpha` and asserts the id round-trips. The previous
|
|
42
|
+
* rule (`agent-` prefix => rename) clobbered it.
|
|
43
|
+
* - PR 348/351 kill tests register `codex-task-holder-1` with model=""
|
|
44
|
+
* and need verbatim round-trip.
|
|
45
|
+
* - `isAnonymousAgent` is intentionally separate and stays permissive
|
|
46
|
+
* (model can flag) for downstream callers that decide whether to
|
|
47
|
+
* *welcome* a participant; the registry hook is stricter.
|
|
48
|
+
*
|
|
49
|
+
* @param {{originalCallerAgentId: string}} params
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
export function shouldAutoRenameInRegistry({ originalCallerAgentId = "" } = {}) {
|
|
53
|
+
const id = normalize(originalCallerAgentId).toLowerCase();
|
|
54
|
+
if (!id) return true;
|
|
55
|
+
return id === "cli-user";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {object} AgentLike
|
|
60
|
+
* @property {string} agentId
|
|
61
|
+
* @property {string} [model]
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
function normalize(value) {
|
|
65
|
+
return String(value == null ? "" : value).trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function familyFromModel(modelName) {
|
|
69
|
+
const lower = normalize(modelName).toLowerCase();
|
|
70
|
+
if (!lower || lower === "unknown" || lower === "anonymous") return "guest";
|
|
71
|
+
if (lower.includes("claude") || lower.includes("sonnet") || lower.includes("opus")) {
|
|
72
|
+
return "claude";
|
|
73
|
+
}
|
|
74
|
+
if (lower.includes("codex") || lower.includes("gpt-")) return "codex";
|
|
75
|
+
if (lower.includes("gemini")) return "gemini";
|
|
76
|
+
if (lower.includes("senti") || lower.includes("sentinel")) return "senti";
|
|
77
|
+
if (lower === "cli") return "guest";
|
|
78
|
+
// Otherwise use the first sanitized token so distinct providers stay
|
|
79
|
+
// distinct even when we don't recognize them.
|
|
80
|
+
const token = lower.split(/[\s:/_-]+/).find(Boolean) || "guest";
|
|
81
|
+
return token.replace(/[^a-z0-9]/g, "") || "guest";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Given the existing agent roster + the model the new participant
|
|
86
|
+
* declared (which may be empty/unknown), pick the next free ordinal
|
|
87
|
+
* within that family and return `<family>-<ordinal>`. Stable across
|
|
88
|
+
* runs because we pass the existing agents in.
|
|
89
|
+
*
|
|
90
|
+
* @param {{model?: string, existingAgents?: Array<AgentLike>}} params
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
export function assignFriendlyName({ model = "", existingAgents = [] } = {}) {
|
|
94
|
+
const family = familyFromModel(model);
|
|
95
|
+
const taken = new Set(
|
|
96
|
+
(Array.isArray(existingAgents) ? existingAgents : [])
|
|
97
|
+
.map((agent) => normalize(agent && agent.agentId).toLowerCase())
|
|
98
|
+
.filter(Boolean),
|
|
99
|
+
);
|
|
100
|
+
for (let n = 1; n <= 9999; n += 1) {
|
|
101
|
+
const candidate = `${family}-${n}`;
|
|
102
|
+
if (!taken.has(candidate)) return candidate;
|
|
103
|
+
}
|
|
104
|
+
// Pathological fallback — should never hit in practice, but a
|
|
105
|
+
// 4-digit ceiling without an escape would be a footgun.
|
|
106
|
+
return `${family}-${Date.now().toString(36)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Decide whether the registration looks anonymous and therefore needs
|
|
111
|
+
* Senti to step in with a friendly name. We treat any of:
|
|
112
|
+
*
|
|
113
|
+
* - empty / fallback agentId (`agent-…`, `cli-user`, `guest-…`)
|
|
114
|
+
* - empty / unknown / cli model
|
|
115
|
+
*
|
|
116
|
+
* as a signal. Either alone is enough — the cli-user default agent
|
|
117
|
+
* still wants Senti's welcome the first time.
|
|
118
|
+
*
|
|
119
|
+
* @param {AgentLike} agent
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
export function isAnonymousAgent(agent = {}) {
|
|
123
|
+
const id = normalize(agent.agentId).toLowerCase();
|
|
124
|
+
const model = normalize(agent.model).toLowerCase();
|
|
125
|
+
const idAnonymous =
|
|
126
|
+
!id ||
|
|
127
|
+
ANONYMOUS_AGENT_PREFIXES.some((prefix) => id.startsWith(prefix));
|
|
128
|
+
const modelAnonymous = ANONYMOUS_MODELS.includes(model);
|
|
129
|
+
return idAnonymous || modelAnonymous;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build the payload Senti emits as `agent_identified` when it has
|
|
134
|
+
* stepped in to name a participant. Consumers (CLI / web) render it
|
|
135
|
+
* verbatim; the `instructions` line tells the user how to override.
|
|
136
|
+
*
|
|
137
|
+
* @param {{
|
|
138
|
+
* agentId: string,
|
|
139
|
+
* model?: string,
|
|
140
|
+
* role?: string,
|
|
141
|
+
* sessionId?: string,
|
|
142
|
+
* wasAnonymous: boolean,
|
|
143
|
+
* originalAgentId?: string,
|
|
144
|
+
* }} params
|
|
145
|
+
* @returns {{
|
|
146
|
+
* alert: "agent_identified",
|
|
147
|
+
* agentId: string,
|
|
148
|
+
* model: string,
|
|
149
|
+
* role: string,
|
|
150
|
+
* wasAnonymous: boolean,
|
|
151
|
+
* originalAgentId: string,
|
|
152
|
+
* message: string,
|
|
153
|
+
* instructions: string,
|
|
154
|
+
* }}
|
|
155
|
+
*/
|
|
156
|
+
export function buildSentiWelcome({
|
|
157
|
+
agentId,
|
|
158
|
+
model = "unknown",
|
|
159
|
+
role = "observer",
|
|
160
|
+
wasAnonymous = false,
|
|
161
|
+
originalAgentId = "",
|
|
162
|
+
} = {}) {
|
|
163
|
+
const cleanModel = normalize(model) || "unknown";
|
|
164
|
+
const cleanRole = normalize(role) || "observer";
|
|
165
|
+
const cleanId = normalize(agentId);
|
|
166
|
+
const message = wasAnonymous
|
|
167
|
+
? `Welcome ${cleanId}. I auto-named you because you joined without a name; introduce yourself anytime.`
|
|
168
|
+
: `Welcome ${cleanId}. You're in as ${cleanRole}.`;
|
|
169
|
+
const instructions = `Update with: sl session rename <sessionId> ${cleanId} --to <new-id> [--model <model>]`;
|
|
170
|
+
return {
|
|
171
|
+
alert: "agent_identified",
|
|
172
|
+
agentId: cleanId,
|
|
173
|
+
model: cleanModel,
|
|
174
|
+
role: cleanRole,
|
|
175
|
+
wasAnonymous: Boolean(wasAnonymous),
|
|
176
|
+
originalAgentId: normalize(originalAgentId),
|
|
177
|
+
message,
|
|
178
|
+
instructions,
|
|
179
|
+
};
|
|
180
|
+
}
|
package/src/session/sync.js
CHANGED
|
@@ -735,6 +735,161 @@ export async function pollHumanMessages(
|
|
|
735
735
|
}
|
|
736
736
|
}
|
|
737
737
|
|
|
738
|
+
/**
|
|
739
|
+
* List sessions owned by the active user via `GET /api/v1/sessions`.
|
|
740
|
+
*
|
|
741
|
+
* Mirrors the failure shape of `pollHumanMessages` so callers can render
|
|
742
|
+
* a single error path: `{ ok, reason, sessions, count }`. Sessions are
|
|
743
|
+
* returned in API order (newest-first per the server's contract); the
|
|
744
|
+
* caller is responsible for any further sort or filter.
|
|
745
|
+
*
|
|
746
|
+
* @param {object} [options]
|
|
747
|
+
* @param {string} [options.targetPath]
|
|
748
|
+
* @param {boolean} [options.includeArchived]
|
|
749
|
+
* @param {number} [options.limit]
|
|
750
|
+
* @param {Function} [options.resolveAuthSession]
|
|
751
|
+
* @param {Function} [options.fetchImpl]
|
|
752
|
+
* @returns {Promise<{ok: boolean, reason: string, sessions: Array<object>, count: number}>}
|
|
753
|
+
*/
|
|
754
|
+
export async function listSessionsFromApi({
|
|
755
|
+
targetPath = process.cwd(),
|
|
756
|
+
includeArchived = false,
|
|
757
|
+
limit = 50,
|
|
758
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
759
|
+
fetchImpl = fetchWithTimeout,
|
|
760
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
761
|
+
} = {}) {
|
|
762
|
+
let session;
|
|
763
|
+
try {
|
|
764
|
+
session = await resolveAuthSession({
|
|
765
|
+
cwd: targetPath,
|
|
766
|
+
env: process.env,
|
|
767
|
+
autoRotate: false,
|
|
768
|
+
});
|
|
769
|
+
} catch {
|
|
770
|
+
return { ok: false, reason: "no_session", sessions: [], count: 0 };
|
|
771
|
+
}
|
|
772
|
+
if (!session || !session.token) {
|
|
773
|
+
return { ok: false, reason: "not_authenticated", sessions: [], count: 0 };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
777
|
+
const query = new URLSearchParams();
|
|
778
|
+
if (includeArchived) query.set("include_archived", "true");
|
|
779
|
+
const normalizedLimit = Math.max(1, Math.min(200, normalizePositiveInteger(limit, 50)));
|
|
780
|
+
query.set("limit", String(normalizedLimit));
|
|
781
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions?${query.toString()}`;
|
|
782
|
+
|
|
783
|
+
let response;
|
|
784
|
+
try {
|
|
785
|
+
response = await fetchImpl(
|
|
786
|
+
endpoint,
|
|
787
|
+
{
|
|
788
|
+
method: "GET",
|
|
789
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
790
|
+
},
|
|
791
|
+
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
|
|
792
|
+
);
|
|
793
|
+
} catch (err) {
|
|
794
|
+
return {
|
|
795
|
+
ok: false,
|
|
796
|
+
reason: normalizeString(err?.message) || "list_failed",
|
|
797
|
+
sessions: [],
|
|
798
|
+
count: 0,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
if (!response || !response.ok) {
|
|
802
|
+
return {
|
|
803
|
+
ok: false,
|
|
804
|
+
reason: `api_${response ? response.status : "no_response"}`,
|
|
805
|
+
sessions: [],
|
|
806
|
+
count: 0,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
const payload = await response.json().catch(() => ({}));
|
|
810
|
+
const sessions = Array.isArray(payload?.sessions) ? payload.sessions : [];
|
|
811
|
+
return {
|
|
812
|
+
ok: true,
|
|
813
|
+
reason: "",
|
|
814
|
+
sessions,
|
|
815
|
+
count: typeof payload?.count === "number" ? payload.count : sessions.length,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Probe whether a single session is visible to the active user.
|
|
821
|
+
*
|
|
822
|
+
* Used by `session sync` to discriminate between "owned but empty" and
|
|
823
|
+
* "not a member / wrong session id" — the former is a quiet success,
|
|
824
|
+
* the latter deserves a loud hint.
|
|
825
|
+
*
|
|
826
|
+
* @returns {Promise<{accessible: boolean, reason: string, status?: number}>}
|
|
827
|
+
*/
|
|
828
|
+
export async function probeSessionAccess(
|
|
829
|
+
sessionId,
|
|
830
|
+
{
|
|
831
|
+
targetPath = process.cwd(),
|
|
832
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
833
|
+
fetchImpl = fetchWithTimeout,
|
|
834
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
835
|
+
} = {},
|
|
836
|
+
) {
|
|
837
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
838
|
+
if (!normalizedSessionId) {
|
|
839
|
+
return { accessible: false, reason: "invalid_session_id" };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
let session;
|
|
843
|
+
try {
|
|
844
|
+
session = await resolveAuthSession({
|
|
845
|
+
cwd: targetPath,
|
|
846
|
+
env: process.env,
|
|
847
|
+
autoRotate: false,
|
|
848
|
+
});
|
|
849
|
+
} catch {
|
|
850
|
+
return { accessible: false, reason: "no_session" };
|
|
851
|
+
}
|
|
852
|
+
if (!session || !session.token) {
|
|
853
|
+
return { accessible: false, reason: "not_authenticated" };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
857
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(
|
|
858
|
+
normalizedSessionId,
|
|
859
|
+
)}/events?limit=1`;
|
|
860
|
+
|
|
861
|
+
let response;
|
|
862
|
+
try {
|
|
863
|
+
response = await fetchImpl(
|
|
864
|
+
endpoint,
|
|
865
|
+
{
|
|
866
|
+
method: "GET",
|
|
867
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
868
|
+
},
|
|
869
|
+
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
|
|
870
|
+
);
|
|
871
|
+
} catch (err) {
|
|
872
|
+
return {
|
|
873
|
+
accessible: false,
|
|
874
|
+
reason: normalizeString(err?.message) || "probe_failed",
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (response && response.ok) {
|
|
879
|
+
return { accessible: true, reason: "", status: response.status };
|
|
880
|
+
}
|
|
881
|
+
if (!response) {
|
|
882
|
+
return { accessible: false, reason: "no_response" };
|
|
883
|
+
}
|
|
884
|
+
if (response.status === 403) {
|
|
885
|
+
return { accessible: false, reason: "not_a_member", status: 403 };
|
|
886
|
+
}
|
|
887
|
+
if (response.status === 404) {
|
|
888
|
+
return { accessible: false, reason: "session_not_found", status: 404 };
|
|
889
|
+
}
|
|
890
|
+
return { accessible: false, reason: `api_${response.status}`, status: response.status };
|
|
891
|
+
}
|
|
892
|
+
|
|
738
893
|
export function resetSessionSyncStateForTests() {
|
|
739
894
|
outboundCircuit.consecutiveFailures = 0;
|
|
740
895
|
outboundCircuit.openedAtMs = 0;
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session transcript renderer — produces an iMessage-style Markdown
|
|
3
|
+
* document from a session's NDJSON event stream + agent roster +
|
|
4
|
+
* metadata. Deterministic: same inputs → identical output (modulo the
|
|
5
|
+
* `generatedAt` line in the header).
|
|
6
|
+
*
|
|
7
|
+
* Adds at render time:
|
|
8
|
+
* - Per-agent active duration (first → last event with that agent id)
|
|
9
|
+
* - Total session live-for (createdAt → last event)
|
|
10
|
+
* - Token + cost roll-up if events carry usage payloads
|
|
11
|
+
* - Avatar per speaker, picked from PERSONA_VISUALS / CLIENT_FAMILY_AVATARS,
|
|
12
|
+
* or a deterministic letter-tile fallback
|
|
13
|
+
* - Senti-orchestrator events tagged with the orchestrator avatar so
|
|
14
|
+
* "if orchestrator did anything it signs its name + time" — even
|
|
15
|
+
* when the underlying event came from a worker job
|
|
16
|
+
*
|
|
17
|
+
* Scales: O(events + agents). Tested up to 20 speakers without degradation.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { PERSONA_VISUALS, ORCHESTRATOR_VISUALS } from "../agents/persona-visuals.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Avatar map for client families (the OUTSIDE-the-persona-set agents
|
|
24
|
+
* that show up in any session: human users, browser-side coding
|
|
25
|
+
* assistants, etc). Keep emoji + a one-color hint so terminal + web
|
|
26
|
+
* renderers can tint consistently.
|
|
27
|
+
*/
|
|
28
|
+
export const CLIENT_FAMILY_AVATARS = Object.freeze({
|
|
29
|
+
human: { avatar: "🧑", color: "blue", label: "Human" },
|
|
30
|
+
claude: { avatar: "🟣", color: "purple", label: "Claude" },
|
|
31
|
+
codex: { avatar: "🟢", color: "green", label: "Codex" },
|
|
32
|
+
gpt: { avatar: "🟢", color: "green", label: "GPT" },
|
|
33
|
+
gemini: { avatar: "🔷", color: "cyan", label: "Gemini" },
|
|
34
|
+
grok: { avatar: "⚫", color: "gray", label: "Grok" },
|
|
35
|
+
cli: { avatar: "💻", color: "white", label: "CLI" },
|
|
36
|
+
guest: { avatar: "👤", color: "gray", label: "Guest" },
|
|
37
|
+
senti: { avatar: "🛡️", color: "gold", label: "Senti" },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const TRANSCRIPT_EVENT_KINDS = new Set([
|
|
41
|
+
"session_message",
|
|
42
|
+
"session_say",
|
|
43
|
+
"agent_response",
|
|
44
|
+
"session_usage",
|
|
45
|
+
"human_relay",
|
|
46
|
+
"agent_join",
|
|
47
|
+
"agent_left",
|
|
48
|
+
"agent_killed",
|
|
49
|
+
"agent_identified",
|
|
50
|
+
"daemon_alert",
|
|
51
|
+
"session_admin_kill",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const SYSTEM_EVENT_KINDS = new Set([
|
|
55
|
+
"agent_join",
|
|
56
|
+
"agent_left",
|
|
57
|
+
"agent_killed",
|
|
58
|
+
"agent_identified",
|
|
59
|
+
"daemon_alert",
|
|
60
|
+
"session_admin_kill",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
function normalize(value) {
|
|
64
|
+
return String(value == null ? "" : value).trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function detectFamily(modelOrId) {
|
|
68
|
+
const v = normalize(modelOrId).toLowerCase();
|
|
69
|
+
if (!v) return "guest";
|
|
70
|
+
if (v.includes("senti") || v.includes("kai-chen")) return "senti";
|
|
71
|
+
if (v.includes("claude") || v.includes("sonnet") || v.includes("opus")) return "claude";
|
|
72
|
+
if (v.includes("codex") || v.startsWith("gpt-") || v === "gpt") return "codex";
|
|
73
|
+
if (v.includes("gemini")) return "gemini";
|
|
74
|
+
if (v.includes("grok")) return "grok";
|
|
75
|
+
if (v.startsWith("human-") || v.includes("human")) return "human";
|
|
76
|
+
if (v === "cli" || v.startsWith("cli-")) return "cli";
|
|
77
|
+
if (v.startsWith("guest-")) return "guest";
|
|
78
|
+
return v.split(/[\s:/_-]+/).find(Boolean) || "guest";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function letterTile(label) {
|
|
82
|
+
const trimmed = normalize(label);
|
|
83
|
+
if (!trimmed) return "·";
|
|
84
|
+
return trimmed.slice(0, 2).toUpperCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve a speaker's display identity given the agent id, model, and
|
|
89
|
+
* whatever profile bag the caller provides (e.g. github avatar URL,
|
|
90
|
+
* google photo URL, friendly name from auth).
|
|
91
|
+
*/
|
|
92
|
+
export function resolveSpeakerIdentity({
|
|
93
|
+
agentId,
|
|
94
|
+
agentModel = "",
|
|
95
|
+
profile = null,
|
|
96
|
+
} = {}) {
|
|
97
|
+
const id = normalize(agentId) || "unknown";
|
|
98
|
+
const lowerId = id.toLowerCase();
|
|
99
|
+
|
|
100
|
+
// 1. Persona visuals — Nina, Maya, Jules, etc.
|
|
101
|
+
if (PERSONA_VISUALS[lowerId]) {
|
|
102
|
+
const v = PERSONA_VISUALS[lowerId];
|
|
103
|
+
return {
|
|
104
|
+
agentId: id,
|
|
105
|
+
family: lowerId,
|
|
106
|
+
avatar: v.avatar,
|
|
107
|
+
avatarUrl: null,
|
|
108
|
+
color: v.color,
|
|
109
|
+
displayName: v.fullName || v.shortName || id,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 2. Orchestrator visuals — Senti / Kai Chen
|
|
114
|
+
if (lowerId === "senti" || lowerId === "kai-chen") {
|
|
115
|
+
const v = ORCHESTRATOR_VISUALS["kai-chen"] || {};
|
|
116
|
+
return {
|
|
117
|
+
agentId: id,
|
|
118
|
+
family: "senti",
|
|
119
|
+
avatar: v.avatar || CLIENT_FAMILY_AVATARS.senti.avatar,
|
|
120
|
+
avatarUrl: null,
|
|
121
|
+
color: v.color || CLIENT_FAMILY_AVATARS.senti.color,
|
|
122
|
+
displayName: v.fullName || "Senti",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 3. Caller-provided profile (github avatar, google photo) wins for humans.
|
|
127
|
+
if (profile && (profile.avatarUrl || profile.displayName)) {
|
|
128
|
+
return {
|
|
129
|
+
agentId: id,
|
|
130
|
+
family: profile.family || detectFamily(id || agentModel),
|
|
131
|
+
avatar: profile.avatar || CLIENT_FAMILY_AVATARS.human.avatar,
|
|
132
|
+
avatarUrl: normalize(profile.avatarUrl) || null,
|
|
133
|
+
color: profile.color || CLIENT_FAMILY_AVATARS.human.color,
|
|
134
|
+
displayName: normalize(profile.displayName) || id,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 4. Client family fallback by model / id pattern.
|
|
139
|
+
const family = detectFamily(agentModel || id);
|
|
140
|
+
const fallback = CLIENT_FAMILY_AVATARS[family] || CLIENT_FAMILY_AVATARS.guest;
|
|
141
|
+
return {
|
|
142
|
+
agentId: id,
|
|
143
|
+
family,
|
|
144
|
+
avatar: fallback.avatar,
|
|
145
|
+
avatarUrl: null,
|
|
146
|
+
color: fallback.color,
|
|
147
|
+
displayName: id || letterTile(family),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function eventTimestamp(event) {
|
|
152
|
+
return normalize(event?.ts || event?.timestamp);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function eventBody(event) {
|
|
156
|
+
const payload = event && typeof event.payload === "object" ? event.payload : {};
|
|
157
|
+
// session_usage carries the response inside payload.response.text
|
|
158
|
+
const responseText =
|
|
159
|
+
typeof payload.response === "object" && payload.response
|
|
160
|
+
? payload.response.text
|
|
161
|
+
: payload.response;
|
|
162
|
+
const text =
|
|
163
|
+
payload.message ||
|
|
164
|
+
responseText ||
|
|
165
|
+
payload.text ||
|
|
166
|
+
payload.alert ||
|
|
167
|
+
payload.reason ||
|
|
168
|
+
"";
|
|
169
|
+
return normalize(text);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Compute deterministic activity stats from the event log:
|
|
174
|
+
* - sessionLiveSeconds: created → last event
|
|
175
|
+
* - perAgent[agentId]: { firstSeen, lastSeen, eventCount, activeSeconds, family, displayName, model }
|
|
176
|
+
* - totals: { tokenTotal, costTotalUsd } summed from any payload.usage hints
|
|
177
|
+
* - sentiActions: count of orchestrator events
|
|
178
|
+
*/
|
|
179
|
+
export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerProfiles = new Map() } = {}) {
|
|
180
|
+
const perAgent = new Map();
|
|
181
|
+
let firstEventTs = null;
|
|
182
|
+
let lastEventTs = null;
|
|
183
|
+
let tokenTotal = 0;
|
|
184
|
+
let costTotalUsd = 0;
|
|
185
|
+
let sentiActions = 0;
|
|
186
|
+
|
|
187
|
+
for (const event of events) {
|
|
188
|
+
const ts = eventTimestamp(event);
|
|
189
|
+
if (!ts) continue;
|
|
190
|
+
const epoch = Date.parse(ts);
|
|
191
|
+
if (!Number.isFinite(epoch)) continue;
|
|
192
|
+
if (firstEventTs == null || epoch < firstEventTs) firstEventTs = epoch;
|
|
193
|
+
if (lastEventTs == null || epoch > lastEventTs) lastEventTs = epoch;
|
|
194
|
+
|
|
195
|
+
const agentId = normalize(event.agent?.id || event.agentId);
|
|
196
|
+
if (!agentId) continue;
|
|
197
|
+
const lowerId = agentId.toLowerCase();
|
|
198
|
+
if (lowerId === "senti" || lowerId === "kai-chen") sentiActions += 1;
|
|
199
|
+
|
|
200
|
+
if (!perAgent.has(agentId)) {
|
|
201
|
+
const profile = speakerProfiles.get(agentId) || null;
|
|
202
|
+
const identity = resolveSpeakerIdentity({
|
|
203
|
+
agentId,
|
|
204
|
+
agentModel: event.agent?.model || event.agentModel || "",
|
|
205
|
+
profile,
|
|
206
|
+
});
|
|
207
|
+
perAgent.set(agentId, {
|
|
208
|
+
agentId,
|
|
209
|
+
family: identity.family,
|
|
210
|
+
displayName: identity.displayName,
|
|
211
|
+
avatar: identity.avatar,
|
|
212
|
+
avatarUrl: identity.avatarUrl,
|
|
213
|
+
color: identity.color,
|
|
214
|
+
model: event.agent?.model || event.agentModel || "",
|
|
215
|
+
firstSeenMs: epoch,
|
|
216
|
+
lastSeenMs: epoch,
|
|
217
|
+
eventCount: 0,
|
|
218
|
+
tokens: 0,
|
|
219
|
+
costUsd: 0,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const record = perAgent.get(agentId);
|
|
223
|
+
record.eventCount += 1;
|
|
224
|
+
if (epoch < record.firstSeenMs) record.firstSeenMs = epoch;
|
|
225
|
+
if (epoch > record.lastSeenMs) record.lastSeenMs = epoch;
|
|
226
|
+
|
|
227
|
+
const usage = event?.payload?.usage;
|
|
228
|
+
if (usage && typeof usage === "object") {
|
|
229
|
+
const t =
|
|
230
|
+
Number(usage.totalTokens || usage.total_tokens || usage.tokens || 0) || 0;
|
|
231
|
+
const c = Number(usage.costUsd || usage.cost_usd || usage.cost || 0) || 0;
|
|
232
|
+
record.tokens += t;
|
|
233
|
+
record.costUsd += c;
|
|
234
|
+
tokenTotal += t;
|
|
235
|
+
costTotalUsd += c;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const createdAtMs = sessionMeta?.createdAt
|
|
240
|
+
? Date.parse(sessionMeta.createdAt)
|
|
241
|
+
: null;
|
|
242
|
+
let startedAtMs;
|
|
243
|
+
if (Number.isFinite(createdAtMs) && firstEventTs != null) {
|
|
244
|
+
// Imported sessions can have events older than the local createdAt;
|
|
245
|
+
// pick the earlier of the two so live-for never goes negative.
|
|
246
|
+
startedAtMs = Math.min(createdAtMs, firstEventTs);
|
|
247
|
+
} else if (Number.isFinite(createdAtMs)) {
|
|
248
|
+
startedAtMs = createdAtMs;
|
|
249
|
+
} else {
|
|
250
|
+
startedAtMs = firstEventTs;
|
|
251
|
+
}
|
|
252
|
+
const sessionLiveSeconds =
|
|
253
|
+
startedAtMs != null && lastEventTs != null
|
|
254
|
+
? Math.max(0, Math.round((lastEventTs - startedAtMs) / 1000))
|
|
255
|
+
: 0;
|
|
256
|
+
|
|
257
|
+
const agents = [];
|
|
258
|
+
for (const record of perAgent.values()) {
|
|
259
|
+
agents.push({
|
|
260
|
+
...record,
|
|
261
|
+
activeSeconds: Math.max(0, Math.round((record.lastSeenMs - record.firstSeenMs) / 1000)),
|
|
262
|
+
firstSeen: new Date(record.firstSeenMs).toISOString(),
|
|
263
|
+
lastSeen: new Date(record.lastSeenMs).toISOString(),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
agents.sort((a, b) => b.eventCount - a.eventCount);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
startedAt: startedAtMs ? new Date(startedAtMs).toISOString() : null,
|
|
270
|
+
endedAt: lastEventTs ? new Date(lastEventTs).toISOString() : null,
|
|
271
|
+
sessionLiveSeconds,
|
|
272
|
+
agents,
|
|
273
|
+
totals: { tokenTotal, costTotalUsd },
|
|
274
|
+
sentiActions,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function formatDuration(seconds) {
|
|
279
|
+
const s = Math.max(0, Math.round(seconds));
|
|
280
|
+
if (s < 60) return `${s}s`;
|
|
281
|
+
const m = Math.floor(s / 60);
|
|
282
|
+
const remS = s % 60;
|
|
283
|
+
if (m < 60) return `${m}m ${remS}s`;
|
|
284
|
+
const h = Math.floor(m / 60);
|
|
285
|
+
const remM = m % 60;
|
|
286
|
+
if (h < 24) return `${h}h ${remM}m`;
|
|
287
|
+
const d = Math.floor(h / 24);
|
|
288
|
+
const remH = h % 24;
|
|
289
|
+
return `${d}d ${remH}h`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function timestampOnly(iso) {
|
|
293
|
+
if (!iso) return "";
|
|
294
|
+
const date = new Date(iso);
|
|
295
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
296
|
+
return date.toISOString().replace("T", " ").replace(/\..+/, " UTC");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function avatarMd(identity) {
|
|
300
|
+
if (identity.avatarUrl) {
|
|
301
|
+
return ``;
|
|
302
|
+
}
|
|
303
|
+
return identity.avatar || letterTile(identity.displayName || identity.agentId);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Build the iMessage-style markdown transcript.
|
|
308
|
+
*
|
|
309
|
+
* @param {object} params
|
|
310
|
+
* @param {object} params.sessionMeta - { sessionId, createdAt, status, ... }
|
|
311
|
+
* @param {Array<object>} params.events
|
|
312
|
+
* @param {Array<object>} [params.agents] - registered agents (optional)
|
|
313
|
+
* @param {Map<string, object>} [params.speakerProfiles] - agentId →
|
|
314
|
+
* { displayName, avatarUrl, family, color }. Used to surface real
|
|
315
|
+
* GitHub / Google photos for human users.
|
|
316
|
+
* @param {object} [params.options]
|
|
317
|
+
* @param {boolean} [params.options.includeSystemEvents=true]
|
|
318
|
+
* @returns {{ markdown: string, stats: object }}
|
|
319
|
+
*/
|
|
320
|
+
export function buildTranscriptMarkdown({
|
|
321
|
+
sessionMeta = {},
|
|
322
|
+
events = [],
|
|
323
|
+
agents = [],
|
|
324
|
+
speakerProfiles = new Map(),
|
|
325
|
+
options = {},
|
|
326
|
+
} = {}) {
|
|
327
|
+
const includeSystemEvents = options.includeSystemEvents !== false;
|
|
328
|
+
const stats = computeTranscriptStats({ sessionMeta, events, speakerProfiles });
|
|
329
|
+
|
|
330
|
+
const lines = [];
|
|
331
|
+
const sessionId = normalize(sessionMeta.sessionId) || "unknown";
|
|
332
|
+
lines.push(`# Session ${sessionId}`);
|
|
333
|
+
lines.push("");
|
|
334
|
+
lines.push(`Generated: ${new Date().toISOString()}`);
|
|
335
|
+
if (stats.startedAt) lines.push(`Started: ${stats.startedAt}`);
|
|
336
|
+
if (stats.endedAt) lines.push(`Last activity: ${stats.endedAt}`);
|
|
337
|
+
lines.push(`Live for: ${formatDuration(stats.sessionLiveSeconds)}`);
|
|
338
|
+
lines.push(`Senti actions: ${stats.sentiActions}`);
|
|
339
|
+
if (stats.totals.tokenTotal > 0 || stats.totals.costTotalUsd > 0) {
|
|
340
|
+
lines.push(
|
|
341
|
+
`Tokens: ${stats.totals.tokenTotal.toLocaleString("en-US")} · Cost: $${stats.totals.costTotalUsd.toFixed(4)}`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
lines.push("");
|
|
345
|
+
|
|
346
|
+
// Participants table
|
|
347
|
+
lines.push("## Participants");
|
|
348
|
+
lines.push("");
|
|
349
|
+
lines.push("| Avatar | Name | Family | Active for | Events | Tokens | Cost |");
|
|
350
|
+
lines.push("|---|---|---|---:|---:|---:|---:|");
|
|
351
|
+
for (const agent of stats.agents) {
|
|
352
|
+
const identity = {
|
|
353
|
+
avatar: agent.avatar,
|
|
354
|
+
avatarUrl: agent.avatarUrl,
|
|
355
|
+
displayName: agent.displayName,
|
|
356
|
+
agentId: agent.agentId,
|
|
357
|
+
};
|
|
358
|
+
lines.push(
|
|
359
|
+
`| ${avatarMd(identity)} | **${agent.displayName}** \`${agent.agentId}\` | ${agent.family} | ${formatDuration(agent.activeSeconds)} | ${agent.eventCount} | ${agent.tokens.toLocaleString("en-US")} | $${agent.costUsd.toFixed(4)} |`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
if (stats.agents.length === 0) {
|
|
363
|
+
lines.push("| 👤 | (no agents joined) | — | 0s | 0 | 0 | $0.00 |");
|
|
364
|
+
}
|
|
365
|
+
// Surface registered-but-silent agents at the bottom of the table so
|
|
366
|
+
// the participants list is comprehensive even if they never emitted
|
|
367
|
+
// a stream event.
|
|
368
|
+
const seenIds = new Set(stats.agents.map((a) => a.agentId));
|
|
369
|
+
for (const registered of agents || []) {
|
|
370
|
+
const id = normalize(registered?.agentId);
|
|
371
|
+
if (!id || seenIds.has(id)) continue;
|
|
372
|
+
const profile = speakerProfiles.get(id) || null;
|
|
373
|
+
const identity = resolveSpeakerIdentity({
|
|
374
|
+
agentId: id,
|
|
375
|
+
agentModel: registered.model || "",
|
|
376
|
+
profile,
|
|
377
|
+
});
|
|
378
|
+
lines.push(
|
|
379
|
+
`| ${avatarMd(identity)} | **${identity.displayName}** \`${id}\` | ${identity.family} | 0s · idle | 0 | 0 | $0.0000 |`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
lines.push("");
|
|
383
|
+
|
|
384
|
+
// Conversation
|
|
385
|
+
lines.push("## Conversation");
|
|
386
|
+
lines.push("");
|
|
387
|
+
for (const event of events) {
|
|
388
|
+
const kind = normalize(event.event || event.type);
|
|
389
|
+
if (!kind || !TRANSCRIPT_EVENT_KINDS.has(kind)) continue;
|
|
390
|
+
if (!includeSystemEvents && SYSTEM_EVENT_KINDS.has(kind)) continue;
|
|
391
|
+
|
|
392
|
+
const agentId = normalize(event.agent?.id || event.agentId);
|
|
393
|
+
const profile = speakerProfiles.get(agentId) || null;
|
|
394
|
+
const identity = resolveSpeakerIdentity({
|
|
395
|
+
agentId,
|
|
396
|
+
agentModel: event.agent?.model || event.agentModel || "",
|
|
397
|
+
profile,
|
|
398
|
+
});
|
|
399
|
+
const ts = eventTimestamp(event);
|
|
400
|
+
const body = eventBody(event);
|
|
401
|
+
|
|
402
|
+
if (SYSTEM_EVENT_KINDS.has(kind)) {
|
|
403
|
+
const hint = body || kind.replace(/_/g, " ");
|
|
404
|
+
lines.push(
|
|
405
|
+
`- _${timestampOnly(ts)} · ${avatarMd(identity)} **${identity.displayName}** ${hint}_`,
|
|
406
|
+
);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
lines.push(`### ${avatarMd(identity)} ${identity.displayName}`);
|
|
411
|
+
lines.push(`> ${timestampOnly(ts)}`);
|
|
412
|
+
lines.push("");
|
|
413
|
+
if (body) {
|
|
414
|
+
const indented = body
|
|
415
|
+
.split(/\r?\n/)
|
|
416
|
+
.map((line) => (line ? line : ""))
|
|
417
|
+
.join("\n");
|
|
418
|
+
lines.push(indented);
|
|
419
|
+
} else {
|
|
420
|
+
lines.push(`_${kind}_`);
|
|
421
|
+
}
|
|
422
|
+
lines.push("");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
markdown: lines.join("\n"),
|
|
427
|
+
stats,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session usage emitter — records every LLM interaction inside a session
|
|
3
|
+
* as a `session_usage` event so consumers (web dashboard, transcript
|
|
4
|
+
* download, telemetry sync) can surface live, accurate token + cost
|
|
5
|
+
* counters per-agent + session-wide.
|
|
6
|
+
*
|
|
7
|
+
* Senti orchestrator philosophy: "tokens on point every time any LLM
|
|
8
|
+
* interacts." Every persona / Jules / Codex / Claude call inside a
|
|
9
|
+
* session should land here so the running tally is authoritative.
|
|
10
|
+
*
|
|
11
|
+
* Event shape:
|
|
12
|
+
*
|
|
13
|
+
* {
|
|
14
|
+
* event: "session_usage",
|
|
15
|
+
* ts: ISO8601,
|
|
16
|
+
* agent: { id, model },
|
|
17
|
+
* payload: {
|
|
18
|
+
* interactionId, // stable id for the LLM call
|
|
19
|
+
* agentId, model, role,
|
|
20
|
+
* inputTokens, outputTokens, totalTokens,
|
|
21
|
+
* costUsd,
|
|
22
|
+
* durationMs, // wall-clock duration of the call
|
|
23
|
+
* prompt: { tokens, chars },
|
|
24
|
+
* response: { tokens, chars, text? },
|
|
25
|
+
* usage: { // mirrors transcript.js payload.usage
|
|
26
|
+
* totalTokens,
|
|
27
|
+
* costUsd,
|
|
28
|
+
* inputTokens,
|
|
29
|
+
* outputTokens,
|
|
30
|
+
* },
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
*
|
|
34
|
+
* Design choice: emit BOTH the convenient flat fields AND a
|
|
35
|
+
* `payload.usage` block, so transcript.js's existing usage roll-up
|
|
36
|
+
* picks it up without changes, while web UIs can display the structured
|
|
37
|
+
* fields directly without re-parsing.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import process from "node:process";
|
|
41
|
+
import { randomUUID } from "node:crypto";
|
|
42
|
+
|
|
43
|
+
import { createAgentEvent } from "../events/schema.js";
|
|
44
|
+
import { resolveSessionPaths } from "./paths.js";
|
|
45
|
+
import { appendToStream } from "./stream.js";
|
|
46
|
+
|
|
47
|
+
const SESSION_USAGE_EVENT = "session_usage";
|
|
48
|
+
|
|
49
|
+
function n(value) {
|
|
50
|
+
return String(value == null ? "" : value).trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function num(value) {
|
|
54
|
+
const v = Number(value);
|
|
55
|
+
return Number.isFinite(v) && v >= 0 ? v : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function clipText(text, max = 4000) {
|
|
59
|
+
const s = n(text);
|
|
60
|
+
if (s.length <= max) return s;
|
|
61
|
+
return `${s.slice(0, max)}…`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Emit a `session_usage` event into the session's NDJSON stream.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} sessionId
|
|
68
|
+
* @param {object} params
|
|
69
|
+
* @param {string} params.agentId
|
|
70
|
+
* @param {string} [params.agentModel]
|
|
71
|
+
* @param {string} [params.role]
|
|
72
|
+
* @param {number} [params.inputTokens]
|
|
73
|
+
* @param {number} [params.outputTokens]
|
|
74
|
+
* @param {number} [params.costUsd]
|
|
75
|
+
* @param {number} [params.durationMs]
|
|
76
|
+
* @param {string} [params.prompt] full prompt text (clipped)
|
|
77
|
+
* @param {string} [params.response] full response text (clipped)
|
|
78
|
+
* @param {string} [params.interactionId] opaque id for cross-event correlation
|
|
79
|
+
* @param {string} [params.targetPath] workspace path (default cwd)
|
|
80
|
+
* @returns {Promise<{ event: string, interactionId: string, totalTokens: number, costUsd: number }>}
|
|
81
|
+
*/
|
|
82
|
+
export async function emitLLMInteraction(
|
|
83
|
+
sessionId,
|
|
84
|
+
{
|
|
85
|
+
agentId,
|
|
86
|
+
agentModel = "",
|
|
87
|
+
role = "",
|
|
88
|
+
inputTokens = 0,
|
|
89
|
+
outputTokens = 0,
|
|
90
|
+
costUsd = 0,
|
|
91
|
+
durationMs = 0,
|
|
92
|
+
prompt = "",
|
|
93
|
+
response = "",
|
|
94
|
+
interactionId = "",
|
|
95
|
+
targetPath = process.cwd(),
|
|
96
|
+
} = {},
|
|
97
|
+
) {
|
|
98
|
+
const sid = n(sessionId);
|
|
99
|
+
if (!sid) throw new Error("sessionId is required.");
|
|
100
|
+
const aid = n(agentId);
|
|
101
|
+
if (!aid) throw new Error("agentId is required.");
|
|
102
|
+
|
|
103
|
+
const paths = resolveSessionPaths(sid, { targetPath });
|
|
104
|
+
const ts = new Date().toISOString();
|
|
105
|
+
const id = n(interactionId) || randomUUID();
|
|
106
|
+
const inT = Math.floor(num(inputTokens));
|
|
107
|
+
const outT = Math.floor(num(outputTokens));
|
|
108
|
+
const totalT = inT + outT;
|
|
109
|
+
const cost = Math.round(num(costUsd) * 1_000_000) / 1_000_000;
|
|
110
|
+
|
|
111
|
+
const promptText = clipText(prompt);
|
|
112
|
+
const responseText = clipText(response);
|
|
113
|
+
|
|
114
|
+
const payload = {
|
|
115
|
+
interactionId: id,
|
|
116
|
+
agentId: aid,
|
|
117
|
+
model: n(agentModel) || "unknown",
|
|
118
|
+
role: n(role) || "observer",
|
|
119
|
+
inputTokens: inT,
|
|
120
|
+
outputTokens: outT,
|
|
121
|
+
totalTokens: totalT,
|
|
122
|
+
costUsd: cost,
|
|
123
|
+
durationMs: Math.max(0, Math.floor(num(durationMs))),
|
|
124
|
+
prompt: { tokens: inT, chars: promptText.length },
|
|
125
|
+
response: {
|
|
126
|
+
tokens: outT,
|
|
127
|
+
chars: responseText.length,
|
|
128
|
+
text: responseText || undefined,
|
|
129
|
+
},
|
|
130
|
+
// Mirror into payload.usage so transcript.js + telemetry sync pick
|
|
131
|
+
// it up via the same code path used for ad-hoc agent_response usage.
|
|
132
|
+
usage: {
|
|
133
|
+
totalTokens: totalT,
|
|
134
|
+
costUsd: cost,
|
|
135
|
+
inputTokens: inT,
|
|
136
|
+
outputTokens: outT,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const envelope = createAgentEvent({
|
|
141
|
+
event: SESSION_USAGE_EVENT,
|
|
142
|
+
agentId: aid,
|
|
143
|
+
agentModel: n(agentModel) || "unknown",
|
|
144
|
+
sessionId: paths.sessionId,
|
|
145
|
+
payload,
|
|
146
|
+
ts,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await appendToStream(paths.sessionId, envelope, { targetPath });
|
|
150
|
+
return {
|
|
151
|
+
event: SESSION_USAGE_EVENT,
|
|
152
|
+
interactionId: id,
|
|
153
|
+
totalTokens: totalT,
|
|
154
|
+
costUsd: cost,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Aggregate `session_usage` events into a per-agent + global tally.
|
|
160
|
+
* Pure helper for renderers that want a snapshot at a point in time.
|
|
161
|
+
*
|
|
162
|
+
* @param {Array<object>} events
|
|
163
|
+
* @returns {{
|
|
164
|
+
* perAgent: Map<string, { agentId, model, totalTokens, inputTokens, outputTokens, costUsd, interactions }>,
|
|
165
|
+
* totals: { totalTokens, inputTokens, outputTokens, costUsd, interactions },
|
|
166
|
+
* }}
|
|
167
|
+
*/
|
|
168
|
+
export function aggregateSessionUsage(events = []) {
|
|
169
|
+
const perAgent = new Map();
|
|
170
|
+
const totals = {
|
|
171
|
+
totalTokens: 0,
|
|
172
|
+
inputTokens: 0,
|
|
173
|
+
outputTokens: 0,
|
|
174
|
+
costUsd: 0,
|
|
175
|
+
interactions: 0,
|
|
176
|
+
};
|
|
177
|
+
for (const event of events) {
|
|
178
|
+
if (!event || event.event !== SESSION_USAGE_EVENT) continue;
|
|
179
|
+
const payload = event.payload || {};
|
|
180
|
+
const agentId = n(payload.agentId || event.agent?.id);
|
|
181
|
+
if (!agentId) continue;
|
|
182
|
+
if (!perAgent.has(agentId)) {
|
|
183
|
+
perAgent.set(agentId, {
|
|
184
|
+
agentId,
|
|
185
|
+
model: n(payload.model || event.agent?.model) || "unknown",
|
|
186
|
+
totalTokens: 0,
|
|
187
|
+
inputTokens: 0,
|
|
188
|
+
outputTokens: 0,
|
|
189
|
+
costUsd: 0,
|
|
190
|
+
interactions: 0,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const record = perAgent.get(agentId);
|
|
194
|
+
record.totalTokens += num(payload.totalTokens);
|
|
195
|
+
record.inputTokens += num(payload.inputTokens);
|
|
196
|
+
record.outputTokens += num(payload.outputTokens);
|
|
197
|
+
record.costUsd += num(payload.costUsd);
|
|
198
|
+
record.interactions += 1;
|
|
199
|
+
|
|
200
|
+
totals.totalTokens += num(payload.totalTokens);
|
|
201
|
+
totals.inputTokens += num(payload.inputTokens);
|
|
202
|
+
totals.outputTokens += num(payload.outputTokens);
|
|
203
|
+
totals.costUsd += num(payload.costUsd);
|
|
204
|
+
totals.interactions += 1;
|
|
205
|
+
}
|
|
206
|
+
totals.costUsd = Math.round(totals.costUsd * 1_000_000) / 1_000_000;
|
|
207
|
+
for (const record of perAgent.values()) {
|
|
208
|
+
record.costUsd = Math.round(record.costUsd * 1_000_000) / 1_000_000;
|
|
209
|
+
}
|
|
210
|
+
return { perAgent, totals };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export const SESSION_USAGE_EVENT_KIND = SESSION_USAGE_EVENT;
|