sentinelayer-cli 0.31.3 → 0.31.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.31.3",
3
+ "version": "0.31.5",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/ai/client.js CHANGED
@@ -146,6 +146,70 @@ function extractTextFromResponse(provider, payload) {
146
146
  return "";
147
147
  }
148
148
 
149
+ function normalizeUsageNumber(value) {
150
+ const parsed = Number(value);
151
+ if (!Number.isFinite(parsed) || parsed < 0) {
152
+ return 0;
153
+ }
154
+ return Math.floor(parsed);
155
+ }
156
+
157
+ function buildUsagePayload({ provider, model, inputTokens = 0, outputTokens = 0, totalTokens = 0 } = {}) {
158
+ const input = normalizeUsageNumber(inputTokens);
159
+ const output = normalizeUsageNumber(outputTokens);
160
+ const total = normalizeUsageNumber(totalTokens) || input + output;
161
+ if (total <= 0 && input + output <= 0) {
162
+ return null;
163
+ }
164
+ return {
165
+ inputTokens: input,
166
+ outputTokens: output,
167
+ totalTokens: total,
168
+ model,
169
+ provider,
170
+ };
171
+ }
172
+
173
+ function extractUsageFromResponse(provider, payload, model) {
174
+ if (!payload || typeof payload !== "object") {
175
+ return null;
176
+ }
177
+
178
+ if (provider === "openai") {
179
+ const usage = payload.usage || {};
180
+ return buildUsagePayload({
181
+ provider,
182
+ model,
183
+ inputTokens: usage.prompt_tokens ?? usage.input_tokens,
184
+ outputTokens: usage.completion_tokens ?? usage.output_tokens,
185
+ totalTokens: usage.total_tokens,
186
+ });
187
+ }
188
+
189
+ if (provider === "anthropic") {
190
+ const usage = payload.usage || {};
191
+ return buildUsagePayload({
192
+ provider,
193
+ model,
194
+ inputTokens: usage.input_tokens,
195
+ outputTokens: usage.output_tokens,
196
+ });
197
+ }
198
+
199
+ if (provider === "google") {
200
+ const usage = payload.usageMetadata || payload.usage_metadata || {};
201
+ return buildUsagePayload({
202
+ provider,
203
+ model,
204
+ inputTokens: usage.promptTokenCount ?? usage.prompt_token_count,
205
+ outputTokens: usage.candidatesTokenCount ?? usage.candidates_token_count,
206
+ totalTokens: usage.totalTokenCount ?? usage.total_token_count,
207
+ });
208
+ }
209
+
210
+ return null;
211
+ }
212
+
149
213
  function buildProviderRequest({ provider, apiKey, model, prompt, stream }) {
150
214
  const normalizedProvider = normalizeProvider(provider);
151
215
  const normalizedPrompt = normalizePrompt(prompt);
@@ -527,6 +591,13 @@ export class MultiProviderApiClient {
527
591
  } else {
528
592
  const payload = await response.json();
529
593
  text = extractTextFromResponse(resolvedProvider, payload);
594
+ const usage = extractUsageFromResponse(resolvedProvider, payload, resolvedModel);
595
+ return {
596
+ provider: resolvedProvider,
597
+ model: resolvedModel,
598
+ text,
599
+ usage: usage || undefined,
600
+ };
530
601
  }
531
602
 
532
603
  return {
@@ -44,6 +44,7 @@ import { stopRuntimeRunsForSession } from "./runtime-bridge.js";
44
44
  import { getSession, renewSession } from "./store.js";
45
45
  import { appendToStream, readStream, tailStream } from "./stream.js";
46
46
  import { handleTaskDirective } from "./tasks.js";
47
+ import { emitLLMInteraction } from "./usage.js";
47
48
 
48
49
  const DAEMON_TICK_INTERVAL_MS = 30_000;
49
50
  const HELP_REQUEST_TIMEOUT_MS = 1_200;
@@ -653,6 +654,28 @@ async function maybeRespondToHelpRequest(
653
654
  nowIso,
654
655
  }
655
656
  );
657
+ if (normalizeUsageNumber(response.usage.inputTokens) + normalizeUsageNumber(response.usage.outputTokens) > 0) {
658
+ // The managed proxy already records the durable API billing/v1 row for
659
+ // this same idempotency key. Keep this copy local for immediate transcript
660
+ // and recap usage without double-counting Session.total_cost_usd remotely.
661
+ await emitLLMInteraction(daemonState.sessionId, {
662
+ agentId: SENTI_IDENTITY.id,
663
+ agentModel: response.usage.model || daemonState.model,
664
+ role: SENTI_IDENTITY.role,
665
+ inputTokens: response.usage.inputTokens,
666
+ outputTokens: response.usage.outputTokens,
667
+ costUsd: response.usage.costUsd,
668
+ durationMs: response.usage.latencyMs,
669
+ action: "proxy_llm",
670
+ provider: response.usage.provider || "sentinelayer",
671
+ billingTier: "internal",
672
+ prompt: normalizeString(requestEvent?.payload?.message) || normalizeString(requestEvent?.payload?.request),
673
+ response: response.message,
674
+ interactionId: `senti:${daemonState.sessionId}:help:${requestId}`,
675
+ targetPath,
676
+ syncRemote: false,
677
+ });
678
+ }
656
679
  return responseEvent;
657
680
  }
658
681
 
@@ -4,6 +4,7 @@ import process from "node:process";
4
4
 
5
5
  import { createAgentEvent } from "../events/schema.js";
6
6
  import { dedupeSessionEvents } from "./event-identity.js";
7
+ import { summarizeListeners } from "./listeners.js";
7
8
  import { resolveSessionPaths } from "./paths.js";
8
9
  import { appendToStream, readStream } from "./stream.js";
9
10
  import { getSession } from "./store.js";
@@ -28,6 +29,10 @@ const RECAP_SOURCE_IGNORED_EVENTS = new Set([
28
29
  "daemon_alert",
29
30
  "session_ack",
30
31
  "session_checkpoint",
32
+ "session_listen_error",
33
+ "session_listener_heartbeat",
34
+ "session_listener_started",
35
+ "session_listener_stopped",
31
36
  "session_reaction",
32
37
  "session_recap",
33
38
  "session_usage",
@@ -648,7 +653,9 @@ function buildRecapKey(sessionId, targetPath) {
648
653
  }
649
654
 
650
655
  function buildRecapText({
651
- activeAgents = [],
656
+ recentActors = [],
657
+ liveListeners = [],
658
+ listenerCount = 0,
652
659
  totalFindings = 0,
653
660
  activeLocks = 0,
654
661
  pendingTasks = 0,
@@ -657,10 +664,20 @@ function buildRecapText({
657
664
  usageSummary = normalizeUsageSummary(),
658
665
  snippets = [],
659
666
  } = {}) {
660
- const agentText =
661
- activeAgents.length > 0
662
- ? `${activeAgents.length} active (${activeAgents.slice(0, 3).join(", ")})`
663
- : "no active peers yet";
667
+ const listenerText =
668
+ liveListeners.length > 0
669
+ ? `${liveListeners.length} live listener${liveListeners.length === 1 ? "" : "s"} (${liveListeners
670
+ .slice(0, 3)
671
+ .join(", ")})`
672
+ : listenerCount > 0
673
+ ? "no live listeners"
674
+ : "listener status unknown";
675
+ const actorText =
676
+ recentActors.length > 0
677
+ ? `${recentActors.length} recent actor${recentActors.length === 1 ? "" : "s"} (${recentActors
678
+ .slice(0, 3)
679
+ .join(", ")})`
680
+ : "no recent peer activity";
664
681
  const findingText = `${totalFindings} finding${totalFindings === 1 ? "" : "s"} logged`;
665
682
  const lockText = `${activeLocks} file lock${activeLocks === 1 ? "" : "s"} active`;
666
683
  const pendingText =
@@ -669,7 +686,7 @@ function buildRecapText({
669
686
  const usageText = buildUsageLedgerText(usageSummary);
670
687
  const workPlanText = buildWorkPlanText(workPlan);
671
688
  const snippetText = snippets.length > 0 ? `Recent: ${snippets.join(" | ")}` : "";
672
- return `While you were away: ${agentText}. ${findingText}. ${lockText}. ${pendingText} ${taskText}. ${workPlanText} ${usageText} ${snippetText}`.replace(
689
+ return `While you were away: ${listenerText}; ${actorText}. ${findingText}. ${lockText}. ${pendingText} ${taskText}. ${workPlanText} ${usageText} ${snippetText}`.replace(
673
690
  /\s+/g,
674
691
  " "
675
692
  ).trim();
@@ -840,18 +857,27 @@ function buildAgentJoinBriefingText({ recap = "", forAgent = "" } = {}) {
840
857
  function buildPeriodicText(recap = {}) {
841
858
  const summary = recap.summary && typeof recap.summary === "object" ? recap.summary : {};
842
859
  const elapsedMinutes = Number(summary.elapsedMinutes || 0);
843
- const activeAgents = Number(summary.activeAgents || 0);
860
+ const recentActors = Number(summary.recentActors ?? summary.activeAgents ?? 0);
861
+ const liveListeners = Number(summary.liveListeners ?? summary.liveListenerCount ?? 0);
862
+ const listenerCount = Number(summary.listenerCount || 0);
844
863
  const totalFindings = Number(summary.totalFindingsCount || 0);
845
864
  const activeLocks = Number(summary.activeLocks || 0);
846
865
  const lastActor = normalizeString(summary.lastActorId);
847
866
  const actorText = lastActor ? `${lastActor} active` : "no active actor";
867
+ const listenerText =
868
+ liveListeners > 0
869
+ ? `${liveListeners} live listener${liveListeners === 1 ? "" : "s"}`
870
+ : listenerCount > 0
871
+ ? "no live listeners"
872
+ : "listener status unknown";
873
+ const recentActorText = `${recentActors} recent actor${recentActors === 1 ? "" : "s"}`;
848
874
  const taskText = buildTaskLedgerText(summary.taskLedger);
849
875
  const workPlanText = buildWorkPlanText(summary.workPlan);
850
876
  const usageText = buildUsageLedgerText({
851
877
  totals: summary.usageTotals,
852
878
  topAgents: summary.usageTopAgents,
853
879
  });
854
- return `Session active for ${elapsedMinutes}m. ${activeAgents} agents. ${totalFindings} findings. ${activeLocks} locks. ${taskText}. ${workPlanText} ${usageText} ${actorText}.`.replace(
880
+ return `Session active for ${elapsedMinutes}m. ${listenerText}. ${recentActorText}. ${totalFindings} findings. ${activeLocks} locks. ${taskText}. ${workPlanText} ${usageText} ${actorText}.`.replace(
855
881
  /\s+/g,
856
882
  " ",
857
883
  ).trim();
@@ -899,19 +925,28 @@ export async function buildSessionRecap(
899
925
  return !normalizedForAgentId || agentId.toLowerCase() !== normalizedForAgentId.toLowerCase();
900
926
  });
901
927
 
902
- const activeAgentSet = new Set();
903
- for (const event of visibleEvents) {
928
+ const listenerRows = summarizeListeners(sortedEvents, {
929
+ nowMs: toEpoch(normalizedNow, normalizedNow),
930
+ });
931
+ const liveListenerRows = listenerRows.filter(
932
+ (row) => row.status === "active" || row.status === "idle",
933
+ );
934
+ const liveListenerIds = liveListenerRows.map((row) => row.agentId).filter(Boolean);
935
+
936
+ const actorEvents = visibleEvents.filter(isMeaningfulRecapSourceEvent);
937
+ const recentActorSet = new Set();
938
+ for (const event of actorEvents) {
904
939
  const agentId = normalizeString(event.agent?.id || event.agentId);
905
940
  if (agentId && agentId !== SENTI_AGENT_ID) {
906
- activeAgentSet.add(agentId);
941
+ recentActorSet.add(agentId);
907
942
  }
908
943
  }
909
- const activeAgents = [...activeAgentSet].sort((left, right) => left.localeCompare(right));
944
+ const recentActors = [...recentActorSet].sort((left, right) => left.localeCompare(right));
910
945
 
911
- const findingSummary = buildFindingSummary(visibleEvents);
946
+ const findingSummary = buildFindingSummary(actorEvents);
912
947
  const totalFindingsCount =
913
948
  findingSummary.P0 + findingSummary.P1 + findingSummary.P2 + findingSummary.P3;
914
- const activeLocks = countActiveLocks(visibleEvents);
949
+ const activeLocks = countActiveLocks(actorEvents);
915
950
  const pendingTasks = await readPendingTasks(normalizedSessionId, {
916
951
  forAgentId: normalizedForAgentId,
917
952
  targetPath: normalizedTargetPath,
@@ -922,17 +957,19 @@ export async function buildSessionRecap(
922
957
  const workPlan = await readWorkPlanSummary({
923
958
  targetPath: normalizedTargetPath,
924
959
  });
925
- const snippets = summarizeRecentActivity(visibleEvents, {
960
+ const snippets = summarizeRecentActivity(actorEvents, {
926
961
  forAgentId: normalizedForAgentId,
927
962
  limit: 2,
928
963
  });
929
- const windowElapsedMinutes = buildElapsedMinutes(visibleEvents, normalizedNow);
930
- const elapsedMinutes = buildElapsedMinutes(visibleEvents, normalizedNow, {
964
+ const windowElapsedMinutes = buildElapsedMinutes(actorEvents, normalizedNow);
965
+ const elapsedMinutes = buildElapsedMinutes(actorEvents, normalizedNow, {
931
966
  startedAt: sessionMetadata?.createdAt,
932
967
  });
933
- const latestEvent = visibleEvents.length > 0 ? visibleEvents[visibleEvents.length - 1] : null;
968
+ const latestEvent = actorEvents.length > 0 ? actorEvents[actorEvents.length - 1] : null;
934
969
  const recapText = buildRecapText({
935
- activeAgents,
970
+ recentActors,
971
+ liveListeners: liveListenerIds,
972
+ listenerCount: listenerRows.length,
936
973
  totalFindings: totalFindingsCount,
937
974
  activeLocks,
938
975
  pendingTasks,
@@ -951,8 +988,17 @@ export async function buildSessionRecap(
951
988
  text: recapText,
952
989
  recap: recapText,
953
990
  summary: {
954
- activeAgents: activeAgents.length,
955
- activeAgentIds: activeAgents,
991
+ // Back-compat: historical consumers read activeAgents, but this count is
992
+ // recent transcript actors, not live listener presence.
993
+ activeAgents: recentActors.length,
994
+ activeAgentIds: recentActors,
995
+ recentActors: recentActors.length,
996
+ recentActorIds: recentActors,
997
+ liveListeners: liveListenerIds.length,
998
+ liveListenerIds,
999
+ liveListenerCount: liveListenerIds.length,
1000
+ listenerCount: listenerRows.length,
1001
+ listenerStatus: listenerRows,
956
1002
  totalFindings: findingSummary,
957
1003
  totalFindingsCount,
958
1004
  activeLocks,
@@ -111,6 +111,7 @@ function computedCost({ model, inputTokens, outputTokens }) {
111
111
  * @param {string} [params.response] full response text (clipped)
112
112
  * @param {string} [params.interactionId] opaque id for cross-event correlation
113
113
  * @param {string} [params.targetPath] workspace path (default cwd)
114
+ * @param {boolean} [params.syncRemote] sync local usage event to API (default true)
114
115
  * @returns {Promise<{ event: string, interactionId: string, totalTokens: number, costUsd: number }>}
115
116
  */
116
117
  export async function emitLLMInteraction(
@@ -132,6 +133,7 @@ export async function emitLLMInteraction(
132
133
  response = "",
133
134
  interactionId = "",
134
135
  targetPath = process.cwd(),
136
+ syncRemote = true,
135
137
  } = {},
136
138
  ) {
137
139
  const sid = n(sessionId);
@@ -219,7 +221,7 @@ export async function emitLLMInteraction(
219
221
  ts,
220
222
  });
221
223
 
222
- await appendToStream(paths.sessionId, envelope, { targetPath });
224
+ await appendToStream(paths.sessionId, envelope, { targetPath, syncRemote });
223
225
  return {
224
226
  event: SESSION_USAGE_EVENT,
225
227
  interactionId: id,