sentinelayer-cli 0.18.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,8 @@
7
7
  * Adds at render time:
8
8
  * - Per-agent active duration (first → last event with that agent id)
9
9
  * - Total session live-for (createdAt → last event)
10
- * - Token + cost roll-up if events carry usage payloads
10
+ * - Token + cost roll-up from session_usage events through the
11
+ * pricing ledger, including idempotency dedupe
11
12
  * - Avatar per speaker, picked from PERSONA_VISUALS / CLIENT_FAMILY_AVATARS,
12
13
  * or a deterministic letter-tile fallback
13
14
  * - Senti-orchestrator events tagged with the orchestrator avatar so
@@ -18,6 +19,7 @@
18
19
  */
19
20
 
20
21
  import { PERSONA_VISUALS, ORCHESTRATOR_VISUALS } from "../agents/persona-visuals.js";
22
+ import { buildSessionUsageLedger } from "./pricing-ledger.js";
21
23
 
22
24
  /**
23
25
  * Avatar map for client families (the OUTSIDE-the-persona-set agents
@@ -232,45 +234,39 @@ function eventBody(event) {
232
234
  * Compute deterministic activity stats from the event log:
233
235
  * - sessionLiveSeconds: created → last event
234
236
  * - perAgent[agentId]: { firstSeen, lastSeen, eventCount, activeSeconds, family, displayName, model }
235
- * - totals: { tokenTotal, costTotalUsd } summed from any payload.usage hints
237
+ * - totals: { tokenTotal, costTotalUsd } summed through the pricing ledger
236
238
  * - sentiActions: count of orchestrator events
237
239
  */
238
240
  export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerProfiles = new Map() } = {}) {
239
241
  const perAgent = new Map();
240
242
  let firstEventTs = null;
241
243
  let lastEventTs = null;
242
- let tokenTotal = 0;
243
- let costTotalUsd = 0;
244
244
  let sentiActions = 0;
245
245
 
246
- for (const event of events) {
247
- const ts = eventTimestamp(event);
248
- if (!ts) continue;
249
- const epoch = Date.parse(ts);
250
- if (!Number.isFinite(epoch)) continue;
251
- if (firstEventTs == null || epoch < firstEventTs) firstEventTs = epoch;
252
- if (lastEventTs == null || epoch > lastEventTs) lastEventTs = epoch;
253
-
254
- const agentId = normalize(event.agent?.id || event.agentId);
255
- if (!agentId) continue;
256
- const lowerId = agentId.toLowerCase();
257
- if (lowerId === "senti" || lowerId === "kai-chen") sentiActions += 1;
258
-
259
- if (!perAgent.has(agentId)) {
260
- const profile = speakerProfiles.get(agentId) || null;
246
+ const ensureAgentRecord = ({
247
+ agentId,
248
+ agentModel = "",
249
+ epoch,
250
+ } = {}) => {
251
+ const normalizedAgentId = normalize(agentId);
252
+ if (!normalizedAgentId || !Number.isFinite(epoch)) {
253
+ return null;
254
+ }
255
+ if (!perAgent.has(normalizedAgentId)) {
256
+ const profile = speakerProfiles.get(normalizedAgentId) || null;
261
257
  const identity = resolveSpeakerIdentity({
262
- agentId,
263
- agentModel: event.agent?.model || event.agentModel || "",
258
+ agentId: normalizedAgentId,
259
+ agentModel,
264
260
  profile,
265
261
  });
266
- perAgent.set(agentId, {
267
- agentId,
262
+ perAgent.set(normalizedAgentId, {
263
+ agentId: normalizedAgentId,
268
264
  family: identity.family,
269
265
  displayName: identity.displayName,
270
266
  avatar: identity.avatar,
271
267
  avatarUrl: identity.avatarUrl,
272
268
  color: identity.color,
273
- model: event.agent?.model || event.agentModel || "",
269
+ model: agentModel,
274
270
  firstSeenMs: epoch,
275
271
  lastSeenMs: epoch,
276
272
  eventCount: 0,
@@ -278,21 +274,59 @@ export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerP
278
274
  costUsd: 0,
279
275
  });
280
276
  }
281
- const record = perAgent.get(agentId);
277
+ const record = perAgent.get(normalizedAgentId);
278
+ if (!record.model && agentModel) {
279
+ record.model = agentModel;
280
+ }
281
+ return record;
282
+ };
283
+
284
+ for (const event of events) {
285
+ const ts = eventTimestamp(event);
286
+ if (!ts) continue;
287
+ const epoch = Date.parse(ts);
288
+ if (!Number.isFinite(epoch)) continue;
289
+ if (firstEventTs == null || epoch < firstEventTs) firstEventTs = epoch;
290
+ if (lastEventTs == null || epoch > lastEventTs) lastEventTs = epoch;
291
+
292
+ const agentId = normalize(event.agent?.id || event.agentId);
293
+ if (!agentId) continue;
294
+ const lowerId = agentId.toLowerCase();
295
+ if (lowerId === "senti" || lowerId === "kai-chen") sentiActions += 1;
296
+
297
+ const record = ensureAgentRecord({
298
+ agentId,
299
+ agentModel: event.agent?.model || event.agentModel || "",
300
+ epoch,
301
+ });
302
+ if (!record) continue;
282
303
  record.eventCount += 1;
283
304
  if (epoch < record.firstSeenMs) record.firstSeenMs = epoch;
284
305
  if (epoch > record.lastSeenMs) record.lastSeenMs = epoch;
306
+ }
285
307
 
286
- const usage = event?.payload?.usage;
287
- if (usage && typeof usage === "object") {
288
- const t =
289
- Number(usage.totalTokens || usage.total_tokens || usage.tokens || 0) || 0;
290
- const c = Number(usage.costUsd || usage.cost_usd || usage.cost || 0) || 0;
291
- record.tokens += t;
292
- record.costUsd += c;
293
- tokenTotal += t;
294
- costTotalUsd += c;
308
+ const usageLedger = buildSessionUsageLedger(events, {
309
+ sessionId: normalize(sessionMeta.sessionId),
310
+ });
311
+ const fallbackUsageEpoch =
312
+ lastEventTs ??
313
+ firstEventTs ??
314
+ (Number.isFinite(Date.parse(sessionMeta?.createdAt)) ? Date.parse(sessionMeta.createdAt) : 0);
315
+ for (const entry of usageLedger.entries) {
316
+ const entryEpoch = Number.isFinite(Date.parse(entry.timestamp))
317
+ ? Date.parse(entry.timestamp)
318
+ : fallbackUsageEpoch;
319
+ const record = ensureAgentRecord({
320
+ agentId: entry.agentId,
321
+ agentModel: entry.model,
322
+ epoch: entryEpoch,
323
+ });
324
+ if (!record) continue;
325
+ if ((!record.model || record.model === "unknown") && entry.model && entry.model !== "unknown") {
326
+ record.model = entry.model;
295
327
  }
328
+ record.tokens += entry.totalTokens;
329
+ record.costUsd = Math.round((record.costUsd + entry.providerCostUsd) * 1_000_000) / 1_000_000;
296
330
  }
297
331
 
298
332
  const createdAtMs = sessionMeta?.createdAt
@@ -329,7 +363,19 @@ export function computeTranscriptStats({ sessionMeta = {}, events = [], speakerP
329
363
  endedAt: lastEventTs ? new Date(lastEventTs).toISOString() : null,
330
364
  sessionLiveSeconds,
331
365
  agents,
332
- totals: { tokenTotal, costTotalUsd },
366
+ totals: {
367
+ tokenTotal: usageLedger.totals.totalTokens,
368
+ inputTokens: usageLedger.totals.inputTokens,
369
+ outputTokens: usageLedger.totals.outputTokens,
370
+ costTotalUsd: usageLedger.totals.providerCostUsd,
371
+ customerCostTotalUsd: usageLedger.totals.hasCustomerCost
372
+ ? usageLedger.totals.customerCostUsd
373
+ : null,
374
+ usageEntries: usageLedger.entries.length,
375
+ duplicatesSkipped: usageLedger.duplicatesSkipped,
376
+ unpriced: usageLedger.totals.unpriced,
377
+ priceBookVersions: usageLedger.priceBookVersions,
378
+ },
333
379
  sentiActions,
334
380
  };
335
381
  }
@@ -396,8 +442,12 @@ export function buildTranscriptMarkdown({
396
442
  lines.push(`Live for: ${formatDuration(stats.sessionLiveSeconds)}`);
397
443
  lines.push(`Senti actions: ${stats.sentiActions}`);
398
444
  if (stats.totals.tokenTotal > 0 || stats.totals.costTotalUsd > 0) {
445
+ const billableText =
446
+ stats.totals.customerCostTotalUsd == null
447
+ ? ""
448
+ : ` · Billable: $${stats.totals.customerCostTotalUsd.toFixed(4)}`;
399
449
  lines.push(
400
- `Tokens: ${stats.totals.tokenTotal.toLocaleString("en-US")} · Cost: $${stats.totals.costTotalUsd.toFixed(4)}`,
450
+ `Tokens: ${stats.totals.tokenTotal.toLocaleString("en-US")} · Cost: $${stats.totals.costTotalUsd.toFixed(4)}${billableText}`,
401
451
  );
402
452
  }
403
453
  lines.push("");
@@ -39,9 +39,9 @@
39
39
  * }
40
40
  *
41
41
  * Design choice: emit BOTH the convenient flat fields AND a
42
- * `payload.usage` block, so transcript.js's existing usage roll-up
43
- * picks it up without changes, while web UIs can display the structured
44
- * fields directly without re-parsing.
42
+ * `payload.usage` block. Transcript/download totals flow through
43
+ * pricing-ledger idempotency semantics, while web UIs can display the
44
+ * structured fields directly without re-parsing.
45
45
  */
46
46
 
47
47
  import process from "node:process";
@@ -191,8 +191,8 @@ export async function emitLLMInteraction(
191
191
  chars: responseText.length,
192
192
  text: responseText || undefined,
193
193
  },
194
- // Mirror into payload.usage so transcript.js + telemetry sync pick
195
- // it up via the same code path used for ad-hoc agent_response usage.
194
+ // Mirror into payload.usage for legacy readers and telemetry sync;
195
+ // transcript/download totals use the canonical pricing-ledger fields.
196
196
  usage: {
197
197
  totalTokens: totalT,
198
198
  costUsd: cost,
@@ -0,0 +1,175 @@
1
+ // Claude Code host wake adapter (Wake-Up & Notification Bus, L1).
2
+ //
3
+ // One of the per-host adapters the future `sentid` daemon (L2) drives through a
4
+ // single uniform interface: { hostName, installWakeHook(opts), wake(target) }.
5
+ // The daemon calls `adapter.wake(...)` without caring which CLI is behind it.
6
+ //
7
+ // Ground-truth this encodes (verified against Claude Code hook docs, 2026-05):
8
+ // * An external process CANNOT poke an idle/stopped Claude Code session in
9
+ // place. `asyncRewake` background hooks and `Stop`-hook `decision:"block"`
10
+ // are real, but they only act WITHIN an already-running session.
11
+ // * The only DETERMINISTIC external wake is for the daemon to own the agent
12
+ // lifecycle and (re)spawn `claude --resume <id> "<event>"` per message.
13
+ // So `wake()` is implemented as a daemon-owned resume, while the hook builders
14
+ // expose the in-session primitives for callers that keep a session parked.
15
+ //
16
+ // Borrowed by copy (no imports) from the reference agent-CLI wake patterns:
17
+ // the deferred-hook-result absorption shape and the channel-notification policy
18
+ // gate idea — adapted, not vendored.
19
+
20
+ import { execFile } from "node:child_process";
21
+
22
+ export const hostName = "claude";
23
+
24
+ const DEFAULT_CLAUDE_BIN = "claude";
25
+ const DEFAULT_RESUME_TIMEOUT_MS = 120_000;
26
+ const DEFAULT_ASYNC_HOOK_TIMEOUT_S = 600;
27
+ // Claude Code overrides a Stop hook after it blocks this many times in a row
28
+ // without progress; our release helper mirrors that cap so a parked session
29
+ // can never wedge itself.
30
+ const STOP_BLOCK_CAP = 8;
31
+ const MAX_MESSAGE_CHARS = 16_000;
32
+
33
+ function requireNonEmptyString(value, label) {
34
+ if (typeof value !== "string" || value.trim() === "") {
35
+ throw new TypeError(`claude wake: ${label} must be a non-empty string`);
36
+ }
37
+ return value;
38
+ }
39
+
40
+ function normalizeMessage(message) {
41
+ const text = requireNonEmptyString(message, "message");
42
+ // Cap length so a runaway event payload can't blow the argv / context.
43
+ return text.length > MAX_MESSAGE_CHARS ? text.slice(0, MAX_MESSAGE_CHARS) : text;
44
+ }
45
+
46
+ /**
47
+ * Build the `claude` argv for a daemon-owned resume wake. Returns a plain
48
+ * argument array so callers invoke it via execFile (no shell), which is what
49
+ * keeps an untrusted event message from being interpreted as a command.
50
+ *
51
+ * @param {{ sessionId: string, message: string, print?: boolean, extraArgs?: string[] }} opts
52
+ * @returns {string[]}
53
+ */
54
+ export function buildResumeArgs({ sessionId, message, print = true, extraArgs = [] } = {}) {
55
+ requireNonEmptyString(sessionId, "sessionId");
56
+ const text = normalizeMessage(message);
57
+ if (!Array.isArray(extraArgs) || extraArgs.some((a) => typeof a !== "string")) {
58
+ throw new TypeError("claude wake: extraArgs must be an array of strings");
59
+ }
60
+ const args = ["--resume", sessionId, ...extraArgs];
61
+ // `-p` runs headless/non-interactive, which is what a daemon-driven wake wants.
62
+ if (print) args.push("-p");
63
+ args.push(text);
64
+ return args;
65
+ }
66
+
67
+ /**
68
+ * Build an `asyncRewake` background command-hook fragment. An agent installs
69
+ * this so a long-running background task can wake it: the task exits with code
70
+ * 2 and Claude surfaces its stderr (or stdout) as a system reminder. Implies
71
+ * `async: true`.
72
+ *
73
+ * @param {{ command: string, timeoutSeconds?: number }} opts
74
+ */
75
+ export function buildAsyncRewakeHook({ command, timeoutSeconds = DEFAULT_ASYNC_HOOK_TIMEOUT_S } = {}) {
76
+ requireNonEmptyString(command, "command");
77
+ if (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0) {
78
+ throw new TypeError("claude wake: timeoutSeconds must be a positive integer");
79
+ }
80
+ return { type: "command", command, asyncRewake: true, timeout: timeoutSeconds };
81
+ }
82
+
83
+ /**
84
+ * Build the JSON a `Stop` hook returns to keep a parked session alive: Claude
85
+ * cannot finish the turn and is fed `reason` as the next-turn context.
86
+ *
87
+ * @param {{ reason: string }} opts
88
+ */
89
+ export function buildStopBlockDecision({ reason } = {}) {
90
+ return { decision: "block", reason: requireNonEmptyString(reason, "reason") };
91
+ }
92
+
93
+ /**
94
+ * A Stop hook must release (allow the session to stop) once Claude reports it
95
+ * has already been blocked `stop_hook_active` times, or it would wedge at the
96
+ * built-in cap. Returns true when the hook should let the session stop.
97
+ *
98
+ * @param {{ stop_hook_active?: boolean, stopHookActive?: boolean, blockCount?: number }} hookInput
99
+ */
100
+ export function shouldReleaseStopBlock(hookInput = {}) {
101
+ if (hookInput.stop_hook_active === true || hookInput.stopHookActive === true) return true;
102
+ if (Number.isInteger(hookInput.blockCount) && hookInput.blockCount >= STOP_BLOCK_CAP) return true;
103
+ return false;
104
+ }
105
+
106
+ /**
107
+ * Shared-interface method: produce the settings fragment that installs the
108
+ * wake hook. Returns the fragment (caller decides where to merge it) rather
109
+ * than mutating a user's settings file, so installation stays non-destructive.
110
+ *
111
+ * @param {{ command: string, timeoutSeconds?: number, event?: string }} opts
112
+ */
113
+ export function installWakeHook({ command, timeoutSeconds, event = "Stop" } = {}) {
114
+ const hook = buildAsyncRewakeHook({ command, timeoutSeconds });
115
+ return { hooks: { [event]: [{ hooks: [hook] }] } };
116
+ }
117
+
118
+ /**
119
+ * Shared-interface method the L2 daemon calls. Deterministic external wake =
120
+ * daemon-owned resume: spawn `claude --resume <id> <message>` via execFile
121
+ * (argv array, no shell). Resolves to a structured result; never throws for a
122
+ * non-zero exit — the daemon inspects `ok` and decides whether to retry.
123
+ *
124
+ * @param {{ sessionId: string, message: string, print?: boolean, extraArgs?: string[] }} target
125
+ * @param {{ execFileImpl?: Function, claudeBin?: string, timeoutMs?: number, env?: object }} [deps]
126
+ * @returns {Promise<{ ok: boolean, hostName: string, sessionId: string, code: number|null, reason: string|null }>}
127
+ */
128
+ export function wake(target = {}, deps = {}) {
129
+ const {
130
+ execFileImpl = execFile,
131
+ claudeBin = DEFAULT_CLAUDE_BIN,
132
+ timeoutMs = DEFAULT_RESUME_TIMEOUT_MS,
133
+ env = process.env,
134
+ } = deps;
135
+
136
+ // Build args first so validation errors reject the promise deterministically.
137
+ let args;
138
+ try {
139
+ args = buildResumeArgs(target);
140
+ } catch (error) {
141
+ return Promise.reject(error);
142
+ }
143
+ const sessionId = target.sessionId;
144
+
145
+ return new Promise((resolve) => {
146
+ execFileImpl(
147
+ claudeBin,
148
+ args,
149
+ { timeout: timeoutMs, env, windowsHide: true },
150
+ (error, _stdout, stderr) => {
151
+ if (!error) {
152
+ resolve({ ok: true, hostName, sessionId, code: 0, reason: null });
153
+ return;
154
+ }
155
+ const code = typeof error.code === "number" ? error.code : null;
156
+ const reason = error.killed
157
+ ? "resume_timeout"
158
+ : (typeof stderr === "string" && stderr.trim()) || error.message || "resume_failed";
159
+ resolve({ ok: false, hostName, sessionId, code, reason });
160
+ }
161
+ );
162
+ });
163
+ }
164
+
165
+ export const claudeWakeAdapter = {
166
+ hostName,
167
+ installWakeHook,
168
+ wake,
169
+ buildResumeArgs,
170
+ buildAsyncRewakeHook,
171
+ buildStopBlockDecision,
172
+ shouldReleaseStopBlock,
173
+ };
174
+
175
+ export default claudeWakeAdapter;