sentinelayer-cli 0.8.7 → 0.8.8

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.8.7",
3
+ "version": "0.8.8",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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 { syncSessionMetadataToApi } from "../session/sync.js";
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("List sessions in the local workspace cache")
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 = shouldEmitJson(options, command) ? sessions : sessions.slice(0, limit);
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 (shouldEmitJson(options, command)) {
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 --include-archived to see history.",
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;
@@ -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,423 @@
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
+ "human_relay",
45
+ "agent_join",
46
+ "agent_left",
47
+ "agent_killed",
48
+ "agent_identified",
49
+ "daemon_alert",
50
+ "session_admin_kill",
51
+ ]);
52
+
53
+ const SYSTEM_EVENT_KINDS = new Set([
54
+ "agent_join",
55
+ "agent_left",
56
+ "agent_killed",
57
+ "agent_identified",
58
+ "daemon_alert",
59
+ "session_admin_kill",
60
+ ]);
61
+
62
+ function normalize(value) {
63
+ return String(value == null ? "" : value).trim();
64
+ }
65
+
66
+ function detectFamily(modelOrId) {
67
+ const v = normalize(modelOrId).toLowerCase();
68
+ if (!v) return "guest";
69
+ if (v.includes("senti") || v.includes("kai-chen")) return "senti";
70
+ if (v.includes("claude") || v.includes("sonnet") || v.includes("opus")) return "claude";
71
+ if (v.includes("codex") || v.startsWith("gpt-") || v === "gpt") return "codex";
72
+ if (v.includes("gemini")) return "gemini";
73
+ if (v.includes("grok")) return "grok";
74
+ if (v.startsWith("human-") || v.includes("human")) return "human";
75
+ if (v === "cli" || v.startsWith("cli-")) return "cli";
76
+ if (v.startsWith("guest-")) return "guest";
77
+ return v.split(/[\s:/_-]+/).find(Boolean) || "guest";
78
+ }
79
+
80
+ function letterTile(label) {
81
+ const trimmed = normalize(label);
82
+ if (!trimmed) return "·";
83
+ return trimmed.slice(0, 2).toUpperCase();
84
+ }
85
+
86
+ /**
87
+ * Resolve a speaker's display identity given the agent id, model, and
88
+ * whatever profile bag the caller provides (e.g. github avatar URL,
89
+ * google photo URL, friendly name from auth).
90
+ */
91
+ export function resolveSpeakerIdentity({
92
+ agentId,
93
+ agentModel = "",
94
+ profile = null,
95
+ } = {}) {
96
+ const id = normalize(agentId) || "unknown";
97
+ const lowerId = id.toLowerCase();
98
+
99
+ // 1. Persona visuals — Nina, Maya, Jules, etc.
100
+ if (PERSONA_VISUALS[lowerId]) {
101
+ const v = PERSONA_VISUALS[lowerId];
102
+ return {
103
+ agentId: id,
104
+ family: lowerId,
105
+ avatar: v.avatar,
106
+ avatarUrl: null,
107
+ color: v.color,
108
+ displayName: v.fullName || v.shortName || id,
109
+ };
110
+ }
111
+
112
+ // 2. Orchestrator visuals — Senti / Kai Chen
113
+ if (lowerId === "senti" || lowerId === "kai-chen") {
114
+ const v = ORCHESTRATOR_VISUALS["kai-chen"] || {};
115
+ return {
116
+ agentId: id,
117
+ family: "senti",
118
+ avatar: v.avatar || CLIENT_FAMILY_AVATARS.senti.avatar,
119
+ avatarUrl: null,
120
+ color: v.color || CLIENT_FAMILY_AVATARS.senti.color,
121
+ displayName: v.fullName || "Senti",
122
+ };
123
+ }
124
+
125
+ // 3. Caller-provided profile (github avatar, google photo) wins for humans.
126
+ if (profile && (profile.avatarUrl || profile.displayName)) {
127
+ return {
128
+ agentId: id,
129
+ family: profile.family || detectFamily(id || agentModel),
130
+ avatar: profile.avatar || CLIENT_FAMILY_AVATARS.human.avatar,
131
+ avatarUrl: normalize(profile.avatarUrl) || null,
132
+ color: profile.color || CLIENT_FAMILY_AVATARS.human.color,
133
+ displayName: normalize(profile.displayName) || id,
134
+ };
135
+ }
136
+
137
+ // 4. Client family fallback by model / id pattern.
138
+ const family = detectFamily(agentModel || id);
139
+ const fallback = CLIENT_FAMILY_AVATARS[family] || CLIENT_FAMILY_AVATARS.guest;
140
+ return {
141
+ agentId: id,
142
+ family,
143
+ avatar: fallback.avatar,
144
+ avatarUrl: null,
145
+ color: fallback.color,
146
+ displayName: id || letterTile(family),
147
+ };
148
+ }
149
+
150
+ function eventTimestamp(event) {
151
+ return normalize(event?.ts || event?.timestamp);
152
+ }
153
+
154
+ function eventBody(event) {
155
+ const payload = event && typeof event.payload === "object" ? event.payload : {};
156
+ const text =
157
+ payload.message ||
158
+ payload.response ||
159
+ payload.text ||
160
+ payload.alert ||
161
+ payload.reason ||
162
+ "";
163
+ return normalize(text);
164
+ }
165
+
166
+ /**
167
+ * Compute deterministic activity stats from the event log:
168
+ * - sessionLiveSeconds: created → last event
169
+ * - perAgent[agentId]: { firstSeen, lastSeen, eventCount, activeSeconds, family, displayName, model }
170
+ * - totals: { tokenTotal, costTotalUsd } summed from any payload.usage hints
171
+ * - sentiActions: count of orchestrator events
172
+ */
173
+ export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerProfiles = new Map() } = {}) {
174
+ const perAgent = new Map();
175
+ let firstEventTs = null;
176
+ let lastEventTs = null;
177
+ let tokenTotal = 0;
178
+ let costTotalUsd = 0;
179
+ let sentiActions = 0;
180
+
181
+ for (const event of events) {
182
+ const ts = eventTimestamp(event);
183
+ if (!ts) continue;
184
+ const epoch = Date.parse(ts);
185
+ if (!Number.isFinite(epoch)) continue;
186
+ if (firstEventTs == null || epoch < firstEventTs) firstEventTs = epoch;
187
+ if (lastEventTs == null || epoch > lastEventTs) lastEventTs = epoch;
188
+
189
+ const agentId = normalize(event.agent?.id || event.agentId);
190
+ if (!agentId) continue;
191
+ const lowerId = agentId.toLowerCase();
192
+ if (lowerId === "senti" || lowerId === "kai-chen") sentiActions += 1;
193
+
194
+ if (!perAgent.has(agentId)) {
195
+ const profile = speakerProfiles.get(agentId) || null;
196
+ const identity = resolveSpeakerIdentity({
197
+ agentId,
198
+ agentModel: event.agent?.model || event.agentModel || "",
199
+ profile,
200
+ });
201
+ perAgent.set(agentId, {
202
+ agentId,
203
+ family: identity.family,
204
+ displayName: identity.displayName,
205
+ avatar: identity.avatar,
206
+ avatarUrl: identity.avatarUrl,
207
+ color: identity.color,
208
+ model: event.agent?.model || event.agentModel || "",
209
+ firstSeenMs: epoch,
210
+ lastSeenMs: epoch,
211
+ eventCount: 0,
212
+ tokens: 0,
213
+ costUsd: 0,
214
+ });
215
+ }
216
+ const record = perAgent.get(agentId);
217
+ record.eventCount += 1;
218
+ if (epoch < record.firstSeenMs) record.firstSeenMs = epoch;
219
+ if (epoch > record.lastSeenMs) record.lastSeenMs = epoch;
220
+
221
+ const usage = event?.payload?.usage;
222
+ if (usage && typeof usage === "object") {
223
+ const t =
224
+ Number(usage.totalTokens || usage.total_tokens || usage.tokens || 0) || 0;
225
+ const c = Number(usage.costUsd || usage.cost_usd || usage.cost || 0) || 0;
226
+ record.tokens += t;
227
+ record.costUsd += c;
228
+ tokenTotal += t;
229
+ costTotalUsd += c;
230
+ }
231
+ }
232
+
233
+ const createdAtMs = sessionMeta?.createdAt
234
+ ? Date.parse(sessionMeta.createdAt)
235
+ : null;
236
+ let startedAtMs;
237
+ if (Number.isFinite(createdAtMs) && firstEventTs != null) {
238
+ // Imported sessions can have events older than the local createdAt;
239
+ // pick the earlier of the two so live-for never goes negative.
240
+ startedAtMs = Math.min(createdAtMs, firstEventTs);
241
+ } else if (Number.isFinite(createdAtMs)) {
242
+ startedAtMs = createdAtMs;
243
+ } else {
244
+ startedAtMs = firstEventTs;
245
+ }
246
+ const sessionLiveSeconds =
247
+ startedAtMs != null && lastEventTs != null
248
+ ? Math.max(0, Math.round((lastEventTs - startedAtMs) / 1000))
249
+ : 0;
250
+
251
+ const agents = [];
252
+ for (const record of perAgent.values()) {
253
+ agents.push({
254
+ ...record,
255
+ activeSeconds: Math.max(0, Math.round((record.lastSeenMs - record.firstSeenMs) / 1000)),
256
+ firstSeen: new Date(record.firstSeenMs).toISOString(),
257
+ lastSeen: new Date(record.lastSeenMs).toISOString(),
258
+ });
259
+ }
260
+ agents.sort((a, b) => b.eventCount - a.eventCount);
261
+
262
+ return {
263
+ startedAt: startedAtMs ? new Date(startedAtMs).toISOString() : null,
264
+ endedAt: lastEventTs ? new Date(lastEventTs).toISOString() : null,
265
+ sessionLiveSeconds,
266
+ agents,
267
+ totals: { tokenTotal, costTotalUsd },
268
+ sentiActions,
269
+ };
270
+ }
271
+
272
+ function formatDuration(seconds) {
273
+ const s = Math.max(0, Math.round(seconds));
274
+ if (s < 60) return `${s}s`;
275
+ const m = Math.floor(s / 60);
276
+ const remS = s % 60;
277
+ if (m < 60) return `${m}m ${remS}s`;
278
+ const h = Math.floor(m / 60);
279
+ const remM = m % 60;
280
+ if (h < 24) return `${h}h ${remM}m`;
281
+ const d = Math.floor(h / 24);
282
+ const remH = h % 24;
283
+ return `${d}d ${remH}h`;
284
+ }
285
+
286
+ function timestampOnly(iso) {
287
+ if (!iso) return "";
288
+ const date = new Date(iso);
289
+ if (Number.isNaN(date.getTime())) return iso;
290
+ return date.toISOString().replace("T", " ").replace(/\..+/, " UTC");
291
+ }
292
+
293
+ function avatarMd(identity) {
294
+ if (identity.avatarUrl) {
295
+ return `![${identity.displayName}](${identity.avatarUrl})`;
296
+ }
297
+ return identity.avatar || letterTile(identity.displayName || identity.agentId);
298
+ }
299
+
300
+ /**
301
+ * Build the iMessage-style markdown transcript.
302
+ *
303
+ * @param {object} params
304
+ * @param {object} params.sessionMeta - { sessionId, createdAt, status, ... }
305
+ * @param {Array<object>} params.events
306
+ * @param {Array<object>} [params.agents] - registered agents (optional)
307
+ * @param {Map<string, object>} [params.speakerProfiles] - agentId →
308
+ * { displayName, avatarUrl, family, color }. Used to surface real
309
+ * GitHub / Google photos for human users.
310
+ * @param {object} [params.options]
311
+ * @param {boolean} [params.options.includeSystemEvents=true]
312
+ * @returns {{ markdown: string, stats: object }}
313
+ */
314
+ export function buildTranscriptMarkdown({
315
+ sessionMeta = {},
316
+ events = [],
317
+ agents = [],
318
+ speakerProfiles = new Map(),
319
+ options = {},
320
+ } = {}) {
321
+ const includeSystemEvents = options.includeSystemEvents !== false;
322
+ const stats = computeTranscriptStats({ sessionMeta, events, speakerProfiles });
323
+
324
+ const lines = [];
325
+ const sessionId = normalize(sessionMeta.sessionId) || "unknown";
326
+ lines.push(`# Session ${sessionId}`);
327
+ lines.push("");
328
+ lines.push(`Generated: ${new Date().toISOString()}`);
329
+ if (stats.startedAt) lines.push(`Started: ${stats.startedAt}`);
330
+ if (stats.endedAt) lines.push(`Last activity: ${stats.endedAt}`);
331
+ lines.push(`Live for: ${formatDuration(stats.sessionLiveSeconds)}`);
332
+ lines.push(`Senti actions: ${stats.sentiActions}`);
333
+ if (stats.totals.tokenTotal > 0 || stats.totals.costTotalUsd > 0) {
334
+ lines.push(
335
+ `Tokens: ${stats.totals.tokenTotal.toLocaleString("en-US")} · Cost: $${stats.totals.costTotalUsd.toFixed(4)}`,
336
+ );
337
+ }
338
+ lines.push("");
339
+
340
+ // Participants table
341
+ lines.push("## Participants");
342
+ lines.push("");
343
+ lines.push("| Avatar | Name | Family | Active for | Events | Tokens | Cost |");
344
+ lines.push("|---|---|---|---:|---:|---:|---:|");
345
+ for (const agent of stats.agents) {
346
+ const identity = {
347
+ avatar: agent.avatar,
348
+ avatarUrl: agent.avatarUrl,
349
+ displayName: agent.displayName,
350
+ agentId: agent.agentId,
351
+ };
352
+ lines.push(
353
+ `| ${avatarMd(identity)} | **${agent.displayName}** \`${agent.agentId}\` | ${agent.family} | ${formatDuration(agent.activeSeconds)} | ${agent.eventCount} | ${agent.tokens.toLocaleString("en-US")} | $${agent.costUsd.toFixed(4)} |`,
354
+ );
355
+ }
356
+ if (stats.agents.length === 0) {
357
+ lines.push("| 👤 | (no agents joined) | — | 0s | 0 | 0 | $0.00 |");
358
+ }
359
+ // Surface registered-but-silent agents at the bottom of the table so
360
+ // the participants list is comprehensive even if they never emitted
361
+ // a stream event.
362
+ const seenIds = new Set(stats.agents.map((a) => a.agentId));
363
+ for (const registered of agents || []) {
364
+ const id = normalize(registered?.agentId);
365
+ if (!id || seenIds.has(id)) continue;
366
+ const profile = speakerProfiles.get(id) || null;
367
+ const identity = resolveSpeakerIdentity({
368
+ agentId: id,
369
+ agentModel: registered.model || "",
370
+ profile,
371
+ });
372
+ lines.push(
373
+ `| ${avatarMd(identity)} | **${identity.displayName}** \`${id}\` | ${identity.family} | 0s · idle | 0 | 0 | $0.0000 |`,
374
+ );
375
+ }
376
+ lines.push("");
377
+
378
+ // Conversation
379
+ lines.push("## Conversation");
380
+ lines.push("");
381
+ for (const event of events) {
382
+ const kind = normalize(event.event || event.type);
383
+ if (!kind || !TRANSCRIPT_EVENT_KINDS.has(kind)) continue;
384
+ if (!includeSystemEvents && SYSTEM_EVENT_KINDS.has(kind)) continue;
385
+
386
+ const agentId = normalize(event.agent?.id || event.agentId);
387
+ const profile = speakerProfiles.get(agentId) || null;
388
+ const identity = resolveSpeakerIdentity({
389
+ agentId,
390
+ agentModel: event.agent?.model || event.agentModel || "",
391
+ profile,
392
+ });
393
+ const ts = eventTimestamp(event);
394
+ const body = eventBody(event);
395
+
396
+ if (SYSTEM_EVENT_KINDS.has(kind)) {
397
+ const hint = body || kind.replace(/_/g, " ");
398
+ lines.push(
399
+ `- _${timestampOnly(ts)} · ${avatarMd(identity)} **${identity.displayName}** ${hint}_`,
400
+ );
401
+ continue;
402
+ }
403
+
404
+ lines.push(`### ${avatarMd(identity)} ${identity.displayName}`);
405
+ lines.push(`> ${timestampOnly(ts)}`);
406
+ lines.push("");
407
+ if (body) {
408
+ const indented = body
409
+ .split(/\r?\n/)
410
+ .map((line) => (line ? line : ""))
411
+ .join("\n");
412
+ lines.push(indented);
413
+ } else {
414
+ lines.push(`_${kind}_`);
415
+ }
416
+ lines.push("");
417
+ }
418
+
419
+ return {
420
+ markdown: lines.join("\n"),
421
+ stats,
422
+ };
423
+ }