sentinelayer-cli 0.9.5 → 0.9.7

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.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,162 @@
1
+ import crypto from "node:crypto";
2
+
3
+ import { redactEventPayload } from "../session/redact.js";
4
+ import { computeProviderCost, PRICE_BOOK_VERSION } from "./price-book.js";
5
+
6
+ export const PRICED_ACTIONS = Object.freeze([
7
+ "audit_run",
8
+ "audit_security",
9
+ "audit_frontend",
10
+ "omargate_deep",
11
+ ]);
12
+
13
+ const OMIT_METADATA_KEYS = new Set([
14
+ "content",
15
+ "input",
16
+ "message",
17
+ "messages",
18
+ "output",
19
+ "prompt",
20
+ "prompts",
21
+ "raw",
22
+ "rawtext",
23
+ "response",
24
+ "responses",
25
+ "text",
26
+ ]);
27
+
28
+ function normalizeString(value) {
29
+ return String(value || "").trim();
30
+ }
31
+
32
+ function normalizeTokenCount(value, field) {
33
+ const parsed = Number(value || 0);
34
+ if (!Number.isFinite(parsed) || parsed < 0) {
35
+ throw new Error(`${field} must be a non-negative number.`);
36
+ }
37
+ return Math.floor(parsed);
38
+ }
39
+
40
+ function normalizeIsoTimestamp(value, fallbackIso = new Date().toISOString()) {
41
+ const normalized = normalizeString(value);
42
+ if (!normalized) return fallbackIso;
43
+ const epoch = Date.parse(normalized);
44
+ if (!Number.isFinite(epoch)) {
45
+ throw new Error("createdAt must be an ISO timestamp.");
46
+ }
47
+ return new Date(epoch).toISOString();
48
+ }
49
+
50
+ export function stableHash(value) {
51
+ return crypto.createHash("sha256").update(String(value || "")).digest("hex");
52
+ }
53
+
54
+ function sanitizeMetadataValue(value, depth = 0) {
55
+ if (depth > 8) return "[REDACTED]";
56
+ if (Array.isArray(value)) {
57
+ return value
58
+ .map((item) => sanitizeMetadataValue(item, depth + 1))
59
+ .filter((item) => item !== undefined);
60
+ }
61
+ if (value && typeof value === "object") {
62
+ const out = {};
63
+ for (const [key, inner] of Object.entries(value)) {
64
+ const normalizedKey = normalizeString(key).toLowerCase().replace(/[^a-z0-9]/g, "");
65
+ if (!normalizedKey || OMIT_METADATA_KEYS.has(normalizedKey)) {
66
+ continue;
67
+ }
68
+ const sanitized = sanitizeMetadataValue(inner, depth + 1);
69
+ if (sanitized !== undefined) {
70
+ out[key] = sanitized;
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ return value;
76
+ }
77
+
78
+ export function sanitizeBillingMetadata(metadata = {}) {
79
+ const sanitized = sanitizeMetadataValue(metadata);
80
+ const redacted = redactEventPayload({ payload: sanitized });
81
+ return redacted?.payload && typeof redacted.payload === "object" ? redacted.payload : {};
82
+ }
83
+
84
+ export function buildBillingRunId({
85
+ sessionId,
86
+ invocationTimestamp,
87
+ configHash,
88
+ } = {}) {
89
+ const normalizedSessionId = normalizeString(sessionId);
90
+ const normalizedTimestamp = normalizeString(invocationTimestamp);
91
+ const normalizedConfigHash = normalizeString(configHash);
92
+ if (!normalizedSessionId) throw new Error("sessionId is required.");
93
+ if (!normalizedTimestamp) throw new Error("invocationTimestamp is required.");
94
+ if (!normalizedConfigHash) throw new Error("configHash is required.");
95
+ return stableHash(`${normalizedSessionId}|${normalizedTimestamp}|${normalizedConfigHash}`).slice(0, 16);
96
+ }
97
+
98
+ export function buildCallIdempotencyKey({ runId, callIndex = 0 } = {}) {
99
+ const normalizedRunId = normalizeString(runId);
100
+ if (!normalizedRunId) throw new Error("runId is required.");
101
+ const normalizedCallIndex = normalizeTokenCount(callIndex, "callIndex");
102
+ return `${normalizedRunId}:${normalizedCallIndex}`;
103
+ }
104
+
105
+ export function buildLedgerEntry({
106
+ sessionId,
107
+ agentId,
108
+ action,
109
+ model,
110
+ inputTokens = 0,
111
+ outputTokens = 0,
112
+ idempotencyKey,
113
+ billingTier = "internal",
114
+ metadata = {},
115
+ createdAt = "",
116
+ } = {}) {
117
+ const normalizedSessionId = normalizeString(sessionId);
118
+ const normalizedAgentId = normalizeString(agentId);
119
+ const normalizedAction = normalizeString(action);
120
+ const normalizedModel = normalizeString(model);
121
+ const normalizedIdempotencyKey = normalizeString(idempotencyKey);
122
+ if (!normalizedSessionId) throw new Error("sessionId is required.");
123
+ if (!normalizedAgentId) throw new Error("agentId is required.");
124
+ if (!normalizedAction) throw new Error("action is required.");
125
+ if (!normalizedModel) throw new Error("model is required.");
126
+ if (!normalizedIdempotencyKey) throw new Error("idempotencyKey is required.");
127
+
128
+ const priced = computeProviderCost({
129
+ model: normalizedModel,
130
+ inputTokens,
131
+ outputTokens,
132
+ });
133
+ const createdIso = normalizeIsoTimestamp(createdAt);
134
+ return {
135
+ ledgerEntryId: `bill_${stableHash(normalizedIdempotencyKey).slice(0, 16)}`,
136
+ schema: "billing/v1",
137
+ sessionId: normalizedSessionId,
138
+ agentId: normalizedAgentId,
139
+ action: normalizedAction,
140
+ model: normalizedModel,
141
+ canonicalModel: priced.canonicalModel,
142
+ priceBookVersion: PRICE_BOOK_VERSION,
143
+ inputTokens: priced.inputTokens,
144
+ outputTokens: priced.outputTokens,
145
+ totalTokens: priced.totalTokens,
146
+ providerCostUsd: priced.providerCostUsd,
147
+ customerCostUsd: null,
148
+ billingTier: normalizeString(billingTier) || "internal",
149
+ idempotencyKey: normalizedIdempotencyKey,
150
+ unpriced: Boolean(priced.unpriced),
151
+ createdAt: createdIso,
152
+ metadata: sanitizeBillingMetadata(metadata),
153
+ };
154
+ }
155
+
156
+ export function countPricedUsageEvents(events = [], pricedActions = PRICED_ACTIONS) {
157
+ const actions = new Set((Array.isArray(pricedActions) ? pricedActions : []).map(normalizeString));
158
+ return (Array.isArray(events) ? events : []).filter((event) => {
159
+ const payload = event?.payload || {};
160
+ return event?.event === "session_usage" && payload.schema === "billing/v1" && actions.has(normalizeString(payload.action));
161
+ }).length;
162
+ }
@@ -0,0 +1,123 @@
1
+ export const PRICE_BOOK_VERSION = "2026-05-19";
2
+
3
+ export const PRICE_BOOK_SOURCES = Object.freeze({
4
+ openai: "https://platform.openai.com/docs/pricing",
5
+ openaiCodex: "https://developers.openai.com/api/docs/models/gpt-5.3-codex",
6
+ anthropic: "https://docs.anthropic.com/en/docs/about-claude/pricing",
7
+ });
8
+
9
+ const RATE = (inputPerMTok, outputPerMTok, source, aliases = []) =>
10
+ Object.freeze({
11
+ inputPerMTok,
12
+ outputPerMTok,
13
+ currency: "USD",
14
+ source,
15
+ aliases,
16
+ });
17
+
18
+ export const PRICE_BOOK = Object.freeze({
19
+ "gpt-5.4-mini": RATE(0.75, 4.5, "openai"),
20
+ "gpt-5.3-codex": RATE(1.75, 14, "openaiCodex"),
21
+ "gpt-5.2-codex": RATE(1.75, 14, "openai"),
22
+ "gpt-5.1-codex": RATE(1.25, 10, "openai"),
23
+ "gpt-5-codex": RATE(1.25, 10, "openai"),
24
+ "gpt-4.1-mini": RATE(0.4, 1.6, "openai"),
25
+ "claude-opus-4.1": RATE(15, 75, "anthropic", ["claude-opus-4-7"]),
26
+ "claude-opus-4": RATE(15, 75, "anthropic", ["claude-opus-4-6"]),
27
+ "claude-sonnet-4": RATE(3, 15, "anthropic", ["claude-sonnet-4-6"]),
28
+ "claude-sonnet-3.7": RATE(3, 15, "anthropic"),
29
+ "claude-sonnet-3.5": RATE(3, 15, "anthropic"),
30
+ "claude-haiku-3.5": RATE(0.8, 4, "anthropic"),
31
+ "claude-haiku-3": RATE(0.25, 1.25, "anthropic"),
32
+ });
33
+
34
+ function normalizeString(value) {
35
+ return String(value || "").trim();
36
+ }
37
+
38
+ function normalizeModel(value) {
39
+ return normalizeString(value).toLowerCase();
40
+ }
41
+
42
+ function normalizeTokenCount(value, field) {
43
+ const parsed = Number(value || 0);
44
+ if (!Number.isFinite(parsed) || parsed < 0) {
45
+ throw new Error(`${field} must be a non-negative number.`);
46
+ }
47
+ return Math.floor(parsed);
48
+ }
49
+
50
+ function round6(value) {
51
+ return Math.round((Number(value || 0) + Number.EPSILON) * 1_000_000) / 1_000_000;
52
+ }
53
+
54
+ const ALIAS_TO_MODEL = Object.freeze(
55
+ Object.entries(PRICE_BOOK).reduce((acc, [model, rate]) => {
56
+ acc[model] = model;
57
+ for (const alias of rate.aliases || []) {
58
+ acc[normalizeModel(alias)] = model;
59
+ }
60
+ return acc;
61
+ }, {}),
62
+ );
63
+
64
+ export function resolvePriceBookRate(model) {
65
+ const requestedModel = normalizeString(model);
66
+ const normalized = normalizeModel(requestedModel);
67
+ const canonicalModel = ALIAS_TO_MODEL[normalized] || normalized;
68
+ const rate = PRICE_BOOK[canonicalModel] || null;
69
+ if (!rate) {
70
+ return {
71
+ model: requestedModel,
72
+ canonicalModel: normalized || requestedModel,
73
+ rate: null,
74
+ unpriced: true,
75
+ priceBookVersion: PRICE_BOOK_VERSION,
76
+ };
77
+ }
78
+ return {
79
+ model: requestedModel || canonicalModel,
80
+ canonicalModel,
81
+ rate,
82
+ unpriced: false,
83
+ priceBookVersion: PRICE_BOOK_VERSION,
84
+ };
85
+ }
86
+
87
+ export function computeProviderCost({
88
+ model,
89
+ inputTokens = 0,
90
+ outputTokens = 0,
91
+ } = {}) {
92
+ const input = normalizeTokenCount(inputTokens, "inputTokens");
93
+ const output = normalizeTokenCount(outputTokens, "outputTokens");
94
+ const resolved = resolvePriceBookRate(model);
95
+ if (resolved.unpriced) {
96
+ return {
97
+ model: resolved.model,
98
+ canonicalModel: resolved.canonicalModel,
99
+ priceBookVersion: PRICE_BOOK_VERSION,
100
+ providerCostUsd: null,
101
+ unpriced: true,
102
+ inputTokens: input,
103
+ outputTokens: output,
104
+ totalTokens: input + output,
105
+ };
106
+ }
107
+ const providerCostUsd = round6(
108
+ (input / 1_000_000) * resolved.rate.inputPerMTok +
109
+ (output / 1_000_000) * resolved.rate.outputPerMTok,
110
+ );
111
+ return {
112
+ model: resolved.model,
113
+ canonicalModel: resolved.canonicalModel,
114
+ priceBookVersion: PRICE_BOOK_VERSION,
115
+ providerCostUsd,
116
+ unpriced: false,
117
+ inputTokens: input,
118
+ outputTokens: output,
119
+ totalTokens: input + output,
120
+ currency: resolved.rate.currency,
121
+ source: resolved.rate.source,
122
+ };
123
+ }
@@ -0,0 +1,80 @@
1
+ import process from "node:process";
2
+
3
+ import { createAgentEvent } from "../events/schema.js";
4
+ import { createSession } from "../session/store.js";
5
+ import { appendToStream } from "../session/stream.js";
6
+ import { buildLedgerEntry } from "./ledger-entry.js";
7
+
8
+ const SESSION_USAGE_EVENT = "session_usage";
9
+
10
+ function normalizeString(value) {
11
+ return String(value || "").trim();
12
+ }
13
+
14
+ async function appendUsageEvent(sessionId, envelope, { targetPath, syncRemote }) {
15
+ return appendToStream(sessionId, envelope, {
16
+ targetPath,
17
+ syncRemote,
18
+ });
19
+ }
20
+
21
+ export async function recordSessionUsage(
22
+ sessionId,
23
+ params = {},
24
+ {
25
+ targetPath = process.cwd(),
26
+ // Billing events originate in the CLI and must reach the API for server-side
27
+ // ledger/quota projection; this intentionally defaults to remote sync.
28
+ syncRemote = true,
29
+ ensureLocalSession = true,
30
+ append = appendUsageEvent,
31
+ } = {},
32
+ ) {
33
+ const normalizedSessionId = normalizeString(sessionId);
34
+ if (!normalizedSessionId) throw new Error("sessionId is required.");
35
+ const entry = buildLedgerEntry({
36
+ ...params,
37
+ sessionId: normalizedSessionId,
38
+ });
39
+ const envelope = createAgentEvent({
40
+ event: SESSION_USAGE_EVENT,
41
+ sessionId: normalizedSessionId,
42
+ agentId: entry.agentId,
43
+ agentModel: entry.model,
44
+ payload: {
45
+ ...entry,
46
+ costUsd: entry.providerCostUsd ?? 0,
47
+ usage: {
48
+ totalTokens: entry.totalTokens,
49
+ inputTokens: entry.inputTokens,
50
+ outputTokens: entry.outputTokens,
51
+ costUsd: entry.providerCostUsd ?? 0,
52
+ providerCostUsd: entry.providerCostUsd,
53
+ customerCostUsd: entry.customerCostUsd,
54
+ },
55
+ },
56
+ ts: entry.createdAt,
57
+ });
58
+
59
+ try {
60
+ await append(normalizedSessionId, envelope, { targetPath, syncRemote });
61
+ } catch (error) {
62
+ if (!ensureLocalSession || !/was not found/i.test(error?.message || "")) {
63
+ throw error;
64
+ }
65
+ await createSession({
66
+ sessionId: normalizedSessionId,
67
+ targetPath,
68
+ title: `Billing usage ${normalizedSessionId}`,
69
+ createdAt: entry.createdAt,
70
+ lastInteractionAt: entry.createdAt,
71
+ });
72
+ await append(normalizedSessionId, envelope, { targetPath, syncRemote });
73
+ }
74
+
75
+ return {
76
+ ok: true,
77
+ event: SESSION_USAGE_EVENT,
78
+ ledgerEntry: entry,
79
+ };
80
+ }
@@ -77,6 +77,11 @@ import {
77
77
  getTemplateRegistry,
78
78
  resolveSessionTemplate,
79
79
  } from "../session/templates.js";
80
+ import {
81
+ createSessionCheckpoint,
82
+ generateSessionCheckpoint,
83
+ listSessionCheckpoints,
84
+ } from "../session/checkpoints.js";
80
85
  import { authLoginHint } from "../ui/command-hints.js";
81
86
  import { parseCsvTokens } from "./ai/shared.js";
82
87
 
@@ -561,6 +566,53 @@ function formatEventLine(event = {}) {
561
566
  return `${ts} ${agentId} ${type}`;
562
567
  }
563
568
 
569
+ function checkpointSequenceRange(checkpoint = {}) {
570
+ const start = Number(checkpoint.startSequence || 0);
571
+ const end = Number(checkpoint.endSequence || 0);
572
+ if (Number.isFinite(start) && start > 0 && Number.isFinite(end) && end > 0) {
573
+ return `#${Math.floor(start)}-${Math.floor(end)}`;
574
+ }
575
+ if (Number.isFinite(start) && start > 0) {
576
+ return `#${Math.floor(start)}`;
577
+ }
578
+ return "anchor pending";
579
+ }
580
+
581
+ function formatCheckpointLine(checkpoint = {}) {
582
+ const id = normalizeString(checkpoint.checkpointId) || "checkpoint";
583
+ const kind = normalizeString(checkpoint.kind) || "summary";
584
+ const title = normalizeString(checkpoint.title) || "Untitled checkpoint";
585
+ const byline = normalizeString(checkpoint.createdByAgentId || checkpoint.createdBy);
586
+ const by = byline ? ` by ${byline}` : "";
587
+ return `${checkpointSequenceRange(checkpoint)} ${id} [${kind}] ${title}${by}`;
588
+ }
589
+
590
+ async function readCheckpointSummaryOption(options = {}, { targetPath } = {}) {
591
+ const inlineSummary = normalizeString(options.summary);
592
+ const summaryFile = normalizeString(options.summaryFile);
593
+ if (inlineSummary && summaryFile) {
594
+ throw new Error("Use either --summary or --summary-file, not both.");
595
+ }
596
+ if (summaryFile) {
597
+ const resolved = path.resolve(targetPath || process.cwd(), summaryFile);
598
+ return fsp.readFile(resolved, "utf-8");
599
+ }
600
+ return inlineSummary;
601
+ }
602
+
603
+ async function hydrateAfterCheckpointMutation(sessionId, { targetPath } = {}) {
604
+ return hydrateSessionFromRemote({
605
+ sessionId,
606
+ targetPath,
607
+ probeOpenCircuit: false,
608
+ eventPageLimit: 200,
609
+ maxEventPages: 5,
610
+ }).catch((error) => ({
611
+ ok: false,
612
+ reason: error instanceof Error ? error.message : "hydrate_failed",
613
+ }));
614
+ }
615
+
564
616
  async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetPath } = {}) {
565
617
  const events = Array.isArray(remoteEvents) ? remoteEvents : [];
566
618
  if (events.length === 0) {
@@ -1884,6 +1936,155 @@ export function registerSessionCommand(program) {
1884
1936
  }
1885
1937
  });
1886
1938
 
1939
+ const checkpoint = session
1940
+ .command("checkpoint")
1941
+ .description("List, create, and generate durable session checkpoints");
1942
+
1943
+ checkpoint
1944
+ .command("list <sessionId>")
1945
+ .description("List durable checkpoints for a remote session")
1946
+ .option("--limit <n>", "Maximum checkpoints to return (default 100, max 200)", "100")
1947
+ .option("--path <path>", "Workspace path for auth/session context", ".")
1948
+ .option("--json", "Emit machine-readable output")
1949
+ .action(async (sessionId, options, command) => {
1950
+ const normalizedSessionId = normalizeString(sessionId);
1951
+ if (!normalizedSessionId) {
1952
+ throw new Error("session id is required.");
1953
+ }
1954
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
1955
+ const result = await listSessionCheckpoints(normalizedSessionId, {
1956
+ targetPath,
1957
+ limit: options.limit,
1958
+ });
1959
+ const payload = {
1960
+ command: "session checkpoint list",
1961
+ targetPath,
1962
+ ...result,
1963
+ };
1964
+ if (shouldEmitJson(options, command)) {
1965
+ console.log(JSON.stringify(payload, null, 2));
1966
+ return;
1967
+ }
1968
+ if (result.checkpoints.length === 0) {
1969
+ console.log(pc.gray(`No checkpoints for session ${normalizedSessionId}.`));
1970
+ return;
1971
+ }
1972
+ for (const item of result.checkpoints) {
1973
+ console.log(formatCheckpointLine(item));
1974
+ }
1975
+ });
1976
+
1977
+ checkpoint
1978
+ .command("create <sessionId>")
1979
+ .description("Create a durable checkpoint anchored to a canonical sequence range")
1980
+ .requiredOption("--start-sequence <n>", "First canonical event sequence included in the checkpoint")
1981
+ .requiredOption("--end-sequence <n>", "Last canonical event sequence included in the checkpoint")
1982
+ .requiredOption("--title <title>", "Short checkpoint title")
1983
+ .option("--summary <text>", "Checkpoint summary text")
1984
+ .option("--summary-file <file>", "Read checkpoint summary text from a file")
1985
+ .option("--kind <kind>", "Checkpoint kind (summary, handoff, milestone, billing)", "summary")
1986
+ .option("--checkpoint-id <id>", "Explicit checkpoint id; defaults to a stable hash of range/body")
1987
+ .option("--agent <id>", "Optional agent id recorded as checkpoint creator")
1988
+ .option("--token-start <n>", "Optional token-range start")
1989
+ .option("--token-end <n>", "Optional token-range end")
1990
+ .option("--path <path>", "Workspace path for auth/session context", ".")
1991
+ .option("--json", "Emit machine-readable output")
1992
+ .action(async (sessionId, options, command) => {
1993
+ const normalizedSessionId = normalizeString(sessionId);
1994
+ if (!normalizedSessionId) {
1995
+ throw new Error("session id is required.");
1996
+ }
1997
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
1998
+ const agentId = normalizeString(options.agent)
1999
+ ? await defaultAgentId(options.agent, targetPath)
2000
+ : "";
2001
+ const summary = await readCheckpointSummaryOption(options, { targetPath });
2002
+ const result = await createSessionCheckpoint(normalizedSessionId, {
2003
+ targetPath,
2004
+ checkpointId: options.checkpointId,
2005
+ startSequence: options.startSequence,
2006
+ endSequence: options.endSequence,
2007
+ kind: options.kind,
2008
+ title: options.title,
2009
+ summary,
2010
+ createdByAgentId: agentId,
2011
+ tokenStart: options.tokenStart,
2012
+ tokenEnd: options.tokenEnd,
2013
+ });
2014
+ const hydration = await hydrateAfterCheckpointMutation(normalizedSessionId, { targetPath });
2015
+ const payload = {
2016
+ command: "session checkpoint create",
2017
+ targetPath,
2018
+ ...result,
2019
+ hydration,
2020
+ };
2021
+ if (shouldEmitJson(options, command)) {
2022
+ console.log(JSON.stringify(payload, null, 2));
2023
+ return;
2024
+ }
2025
+ const duplicate = result.duplicate ? "duplicate " : "";
2026
+ console.log(pc.bold(`${duplicate}checkpoint created`));
2027
+ if (result.checkpoint) {
2028
+ console.log(formatCheckpointLine(result.checkpoint));
2029
+ }
2030
+ if (!hydration.ok) {
2031
+ console.log(pc.gray(`Local hydrate skipped: ${hydration.reason || "unknown"}`));
2032
+ }
2033
+ });
2034
+
2035
+ checkpoint
2036
+ .command("generate <sessionId>")
2037
+ .description("Generate a checkpoint from the next uncheckpointed durable event window")
2038
+ .option("--min-events <n>", "Minimum source events required before creating (default 20)", "20")
2039
+ .option("--max-events <n>", "Maximum source events to summarize (default 80, max 200)", "80")
2040
+ .option("--operation-id <key>", "Explicit retry key for this generate invocation")
2041
+ .option("--agent <id>", "Optional agent id recorded as checkpoint creator")
2042
+ .option("--path <path>", "Workspace path for auth/session context", ".")
2043
+ .option("--json", "Emit machine-readable output")
2044
+ .action(async (sessionId, options, command) => {
2045
+ const normalizedSessionId = normalizeString(sessionId);
2046
+ if (!normalizedSessionId) {
2047
+ throw new Error("session id is required.");
2048
+ }
2049
+ const targetPath = path.resolve(process.cwd(), String(options.path || "."));
2050
+ const agentId = normalizeString(options.agent)
2051
+ ? await defaultAgentId(options.agent, targetPath)
2052
+ : "";
2053
+ const result = await generateSessionCheckpoint(normalizedSessionId, {
2054
+ targetPath,
2055
+ minEvents: options.minEvents,
2056
+ maxEvents: options.maxEvents,
2057
+ idempotencyKey: options.operationId,
2058
+ createdByAgentId: agentId,
2059
+ });
2060
+ const hydration = result.checkpoint
2061
+ ? await hydrateAfterCheckpointMutation(normalizedSessionId, { targetPath })
2062
+ : null;
2063
+ const payload = {
2064
+ command: "session checkpoint generate",
2065
+ targetPath,
2066
+ ...result,
2067
+ hydration: hydration || undefined,
2068
+ };
2069
+ if (shouldEmitJson(options, command)) {
2070
+ console.log(JSON.stringify(payload, null, 2));
2071
+ return;
2072
+ }
2073
+ if (result.checkpoint) {
2074
+ console.log(pc.bold(result.duplicate ? "checkpoint already covered" : "checkpoint generated"));
2075
+ console.log(formatCheckpointLine(result.checkpoint));
2076
+ if (hydration && !hydration.ok) {
2077
+ console.log(pc.gray(`Local hydrate skipped: ${hydration.reason || "unknown"}`));
2078
+ }
2079
+ return;
2080
+ }
2081
+ console.log(
2082
+ pc.gray(
2083
+ `No checkpoint created: ${normalizeString(result.reason) || "not_needed"} (${Number(result.eventCount || 0)} events, min ${Number(result.minEvents || 0)}).`,
2084
+ ),
2085
+ );
2086
+ });
2087
+
1887
2088
  session
1888
2089
  .command("status <sessionId>")
1889
2090
  .description("Show session status, agents, and health")
@@ -6,8 +6,16 @@ const DEFAULT_MODEL_PRICING = Object.freeze({
6
6
  outputPerMillionUsd: 10.0,
7
7
  }),
8
8
  "gpt-5.3-codex": Object.freeze({
9
- inputPerMillionUsd: 1.5,
10
- outputPerMillionUsd: 6.0,
9
+ inputPerMillionUsd: 1.75,
10
+ outputPerMillionUsd: 14.0,
11
+ }),
12
+ "gpt-5.4-mini": Object.freeze({
13
+ inputPerMillionUsd: 0.75,
14
+ outputPerMillionUsd: 4.5,
15
+ }),
16
+ "gpt-4.1-mini": Object.freeze({
17
+ inputPerMillionUsd: 0.4,
18
+ outputPerMillionUsd: 1.6,
11
19
  }),
12
20
  "claude-sonnet-4": Object.freeze({
13
21
  inputPerMillionUsd: 3.0,
@@ -21,6 +29,14 @@ const DEFAULT_MODEL_PRICING = Object.freeze({
21
29
  inputPerMillionUsd: 3.0,
22
30
  outputPerMillionUsd: 15.0,
23
31
  }),
32
+ "claude-opus-4": Object.freeze({
33
+ inputPerMillionUsd: 15.0,
34
+ outputPerMillionUsd: 75.0,
35
+ }),
36
+ "claude-opus-4.1": Object.freeze({
37
+ inputPerMillionUsd: 15.0,
38
+ outputPerMillionUsd: 75.0,
39
+ }),
24
40
  "claude-opus-4-6": Object.freeze({
25
41
  inputPerMillionUsd: 15.0,
26
42
  outputPerMillionUsd: 75.0,
@@ -29,6 +45,14 @@ const DEFAULT_MODEL_PRICING = Object.freeze({
29
45
  inputPerMillionUsd: 15.0,
30
46
  outputPerMillionUsd: 75.0,
31
47
  }),
48
+ "claude-haiku-3.5": Object.freeze({
49
+ inputPerMillionUsd: 0.8,
50
+ outputPerMillionUsd: 4.0,
51
+ }),
52
+ "claude-haiku-3": Object.freeze({
53
+ inputPerMillionUsd: 0.25,
54
+ outputPerMillionUsd: 1.25,
55
+ }),
32
56
  "gemini-2.5-pro": Object.freeze({
33
57
  inputPerMillionUsd: 2.5,
34
58
  outputPerMillionUsd: 10.0,
@@ -1,15 +1,18 @@
1
1
  import fsp from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import crypto from "node:crypto";
3
4
 
4
5
  import {
5
6
  createMultiProviderApiClient,
6
7
  resolveModel,
7
8
  resolveProvider,
8
9
  } from "../ai/client.js";
10
+ import { computeProviderCost } from "../billing/price-book.js";
11
+ import { buildBillingRunId, buildCallIdempotencyKey } from "../billing/ledger-entry.js";
12
+ import { recordSessionUsage } from "../billing/session-usage.js";
9
13
  import { loadConfig } from "../config/service.js";
10
14
  import { evaluateBudget } from "../cost/budget.js";
11
15
  import { appendCostEntry, summarizeCostHistory } from "../cost/history.js";
12
- import { estimateModelCost } from "../cost/tracker.js";
13
16
  import { estimateTokens } from "../cost/tokenizer.js";
14
17
  import { appendRunEvent, deriveStopClassFromBudget } from "../telemetry/ledger.js";
15
18
 
@@ -329,21 +332,22 @@ export function buildAiReviewPrompt({
329
332
  }
330
333
 
331
334
  function maybeEstimateModelCost({ modelId, inputTokens, outputTokens }) {
332
- try {
333
- return {
334
- costUsd: estimateModelCost({
335
- modelId,
336
- inputTokens,
337
- outputTokens,
338
- }),
339
- pricingFound: true,
340
- };
341
- } catch {
342
- return {
343
- costUsd: 0,
344
- pricingFound: false,
345
- };
346
- }
335
+ const priced = computeProviderCost({
336
+ model: modelId,
337
+ inputTokens,
338
+ outputTokens,
339
+ });
340
+ return {
341
+ costUsd: priced.providerCostUsd ?? 0,
342
+ providerCostUsd: priced.providerCostUsd,
343
+ pricingFound: !priced.unpriced,
344
+ priceBookVersion: priced.priceBookVersion,
345
+ canonicalModel: priced.canonicalModel,
346
+ };
347
+ }
348
+
349
+ function stableConfigHash(value = {}) {
350
+ return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex");
347
351
  }
348
352
 
349
353
  function composeAiReviewMarkdown({
@@ -529,6 +533,7 @@ export async function runAiReviewLayer({
529
533
  });
530
534
 
531
535
  const startedAt = Date.now();
536
+ const startedAtIso = new Date(startedAt).toISOString();
532
537
  const responseText = dryRun
533
538
  ? buildDryRunResponse({
534
539
  deterministicSummary: deterministic?.summary || {},
@@ -643,6 +648,53 @@ export async function runAiReviewLayer({
643
648
  }
644
649
  );
645
650
 
651
+ let sessionUsageLedger = null;
652
+ try {
653
+ const billingRunId = buildBillingRunId({
654
+ sessionId: normalizedSessionId,
655
+ invocationTimestamp: startedAtIso,
656
+ configHash: stableConfigHash({
657
+ sourceCommand: "review",
658
+ layer: "ai_reasoning",
659
+ provider: resolvedProvider,
660
+ model: resolvedModel,
661
+ mode: normalizedMode,
662
+ runId: normalizedRunId,
663
+ dryRun: Boolean(dryRun),
664
+ }),
665
+ });
666
+ sessionUsageLedger = await recordSessionUsage(
667
+ normalizedSessionId,
668
+ {
669
+ agentId: "audit-orchestrator",
670
+ action: "audit_run",
671
+ model: resolvedModel,
672
+ inputTokens,
673
+ outputTokens,
674
+ idempotencyKey: buildCallIdempotencyKey({ runId: billingRunId, callIndex: 0 }),
675
+ billingTier: "internal",
676
+ createdAt: startedAtIso,
677
+ metadata: {
678
+ sourceCommand: "review",
679
+ layer: "ai_reasoning",
680
+ provider: resolvedProvider,
681
+ mode: normalizedMode,
682
+ runId: normalizedRunId,
683
+ dryRun: Boolean(dryRun),
684
+ pricingFound: modelCost.pricingFound,
685
+ },
686
+ },
687
+ {
688
+ targetPath: normalizedTargetPath,
689
+ },
690
+ );
691
+ } catch (error) {
692
+ sessionUsageLedger = {
693
+ ok: false,
694
+ reason: error instanceof Error ? error.message : String(error || "session_usage_failed"),
695
+ };
696
+ }
697
+
646
698
  let stopTelemetry = null;
647
699
  if (budget.blocking) {
648
700
  stopTelemetry = await appendRunEvent(
@@ -704,6 +756,7 @@ export async function runAiReviewLayer({
704
756
  dryRun: Boolean(dryRun),
705
757
  usage,
706
758
  pricingFound: modelCost.pricingFound,
759
+ sessionUsageLedger,
707
760
  budget,
708
761
  deterministicSummary,
709
762
  aiSummary,
@@ -756,6 +809,7 @@ export async function runAiReviewLayer({
756
809
  usageEventId: usageTelemetry.event.eventId,
757
810
  stopEventId: stopTelemetry?.event?.eventId || null,
758
811
  },
812
+ billing: sessionUsageLedger,
759
813
  };
760
814
  }
761
815
 
@@ -0,0 +1,294 @@
1
+ import crypto from "node:crypto";
2
+ import process from "node:process";
3
+
4
+ import { requestJson, requestJsonMutation } from "../auth/http.js";
5
+ import { resolveActiveAuthSession } from "../auth/service.js";
6
+
7
+ const DEFAULT_API_BASE_URL = "https://api.sentinelayer.com";
8
+ const DEFAULT_CHECKPOINT_LIMIT = 100;
9
+ const MAX_CHECKPOINT_LIMIT = 200;
10
+ const DEFAULT_MIN_EVENTS = 20;
11
+ const DEFAULT_MAX_EVENTS = 80;
12
+
13
+ function normalizeString(value) {
14
+ return String(value || "").trim();
15
+ }
16
+
17
+ function normalizeApiUrl(value) {
18
+ return (normalizeString(value) || DEFAULT_API_BASE_URL).replace(/\/+$/g, "");
19
+ }
20
+
21
+ function parsePositiveInteger(value, field, fallbackValue = null) {
22
+ if (value === undefined || value === null || normalizeString(value) === "") {
23
+ if (fallbackValue !== null) return fallbackValue;
24
+ throw new Error(`${field} is required.`);
25
+ }
26
+ const parsed = Number(value);
27
+ if (!Number.isFinite(parsed) || parsed <= 0) {
28
+ throw new Error(`${field} must be a positive integer.`);
29
+ }
30
+ return Math.floor(parsed);
31
+ }
32
+
33
+ function normalizeLimit(value) {
34
+ const parsed = parsePositiveInteger(value, "limit", DEFAULT_CHECKPOINT_LIMIT);
35
+ return Math.max(1, Math.min(MAX_CHECKPOINT_LIMIT, parsed));
36
+ }
37
+
38
+ function stableHash(value) {
39
+ return crypto
40
+ .createHash("sha256")
41
+ .update(JSON.stringify(stableJson(value)))
42
+ .digest("hex");
43
+ }
44
+
45
+ function stableJson(value) {
46
+ if (Array.isArray(value)) {
47
+ return value.map((item) => stableJson(item));
48
+ }
49
+ if (!value || typeof value !== "object") {
50
+ return value;
51
+ }
52
+ const out = {};
53
+ for (const key of Object.keys(value).sort()) {
54
+ const next = stableJson(value[key]);
55
+ if (next !== undefined && next !== null && next !== "") {
56
+ out[key] = next;
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+
62
+ function buildCheckpointFingerprint(payload) {
63
+ return stableHash(stableJson(payload));
64
+ }
65
+
66
+ function buildStableCheckpointId(sessionId, payload) {
67
+ const fingerprint = buildCheckpointFingerprint({
68
+ sessionId: normalizeString(sessionId),
69
+ startSequence: payload.startSequence,
70
+ endSequence: payload.endSequence,
71
+ kind: payload.kind,
72
+ title: payload.title,
73
+ summary: payload.summary,
74
+ createdByAgentId: payload.createdByAgentId,
75
+ tokenRange: payload.tokenRange || null,
76
+ });
77
+ return `cp_cli_${fingerprint.slice(0, 24)}`;
78
+ }
79
+
80
+ function buildStableIdempotencyKey(sessionId, operation, payload) {
81
+ const fingerprint = buildCheckpointFingerprint({
82
+ sessionId: normalizeString(sessionId),
83
+ operation,
84
+ payload,
85
+ });
86
+ return `sl_cli_session_checkpoint_${fingerprint}`;
87
+ }
88
+
89
+ function buildInvocationIdempotencyKey(operation) {
90
+ let suffix;
91
+ try {
92
+ suffix = crypto.randomUUID();
93
+ } catch {
94
+ suffix = crypto.randomBytes(16).toString("hex");
95
+ }
96
+ return `sl_cli_session_checkpoint_${normalizeString(operation) || "mutation"}_${suffix}`;
97
+ }
98
+
99
+ function normalizeTokenRange({ tokenStart, tokenEnd } = {}) {
100
+ const hasStart = tokenStart !== undefined && normalizeString(tokenStart) !== "";
101
+ const hasEnd = tokenEnd !== undefined && normalizeString(tokenEnd) !== "";
102
+ if (!hasStart && !hasEnd) return null;
103
+ const start = hasStart ? parsePositiveInteger(tokenStart, "token-start") : null;
104
+ const end = hasEnd ? parsePositiveInteger(tokenEnd, "token-end") : null;
105
+ if (start !== null && end !== null && start > end) {
106
+ throw new Error("token-start must be less than or equal to token-end.");
107
+ }
108
+ return { start, end };
109
+ }
110
+
111
+ export function buildManualCheckpointPayload(sessionId, {
112
+ checkpointId = "",
113
+ startSequence,
114
+ endSequence,
115
+ kind = "summary",
116
+ title,
117
+ summary,
118
+ createdByAgentId = "",
119
+ tokenStart,
120
+ tokenEnd,
121
+ } = {}) {
122
+ const normalizedSessionId = normalizeString(sessionId);
123
+ if (!normalizedSessionId) {
124
+ throw new Error("session id is required.");
125
+ }
126
+ const normalizedStart = parsePositiveInteger(startSequence, "start-sequence");
127
+ const normalizedEnd = parsePositiveInteger(endSequence, "end-sequence");
128
+ if (normalizedStart > normalizedEnd) {
129
+ throw new Error("start-sequence must be less than or equal to end-sequence.");
130
+ }
131
+ const normalizedTitle = normalizeString(title);
132
+ if (!normalizedTitle) {
133
+ throw new Error("title is required.");
134
+ }
135
+ const normalizedSummary = normalizeString(summary);
136
+ if (!normalizedSummary) {
137
+ throw new Error("summary is required.");
138
+ }
139
+ const normalizedKind = normalizeString(kind) || "summary";
140
+ const normalizedCreatedBy = normalizeString(createdByAgentId);
141
+ const tokenRange = normalizeTokenRange({ tokenStart, tokenEnd });
142
+ const body = {
143
+ startSequence: normalizedStart,
144
+ endSequence: normalizedEnd,
145
+ kind: normalizedKind,
146
+ title: normalizedTitle,
147
+ summary: normalizedSummary,
148
+ };
149
+ if (normalizedCreatedBy) {
150
+ body.createdByAgentId = normalizedCreatedBy;
151
+ }
152
+ if (tokenRange) {
153
+ body.tokenRange = tokenRange;
154
+ }
155
+ body.checkpointId = normalizeString(checkpointId) || buildStableCheckpointId(normalizedSessionId, body);
156
+ return {
157
+ body,
158
+ idempotencyKey: buildStableIdempotencyKey(normalizedSessionId, "create", body),
159
+ };
160
+ }
161
+
162
+ export function buildGenerateCheckpointPayload(sessionId, {
163
+ minEvents = DEFAULT_MIN_EVENTS,
164
+ maxEvents = DEFAULT_MAX_EVENTS,
165
+ createdByAgentId = "",
166
+ idempotencyKey = "",
167
+ } = {}) {
168
+ const normalizedSessionId = normalizeString(sessionId);
169
+ if (!normalizedSessionId) {
170
+ throw new Error("session id is required.");
171
+ }
172
+ const normalizedMin = parsePositiveInteger(minEvents, "min-events", DEFAULT_MIN_EVENTS);
173
+ const normalizedMax = parsePositiveInteger(maxEvents, "max-events", DEFAULT_MAX_EVENTS);
174
+ if (normalizedMin > 200) {
175
+ throw new Error("min-events must be less than or equal to 200.");
176
+ }
177
+ if (normalizedMax > 200) {
178
+ throw new Error("max-events must be less than or equal to 200.");
179
+ }
180
+ if (normalizedMax < normalizedMin) {
181
+ throw new Error("max-events must be greater than or equal to min-events.");
182
+ }
183
+ const body = {
184
+ minEvents: normalizedMin,
185
+ maxEvents: normalizedMax,
186
+ };
187
+ const normalizedCreatedBy = normalizeString(createdByAgentId);
188
+ if (normalizedCreatedBy) {
189
+ body.createdByAgentId = normalizedCreatedBy;
190
+ }
191
+ return {
192
+ body,
193
+ idempotencyKey:
194
+ normalizeString(idempotencyKey) || buildInvocationIdempotencyKey("generate"),
195
+ };
196
+ }
197
+
198
+ async function resolveCheckpointApi({
199
+ targetPath = process.cwd(),
200
+ resolveAuthSession = resolveActiveAuthSession,
201
+ } = {}) {
202
+ const auth = await resolveAuthSession({
203
+ cwd: targetPath,
204
+ env: process.env,
205
+ autoRotate: false,
206
+ });
207
+ if (!auth || !auth.token) {
208
+ throw new Error("Sentinelayer auth is required. Run `sl auth login` first.");
209
+ }
210
+ return {
211
+ apiUrl: normalizeApiUrl(auth.apiUrl),
212
+ headers: { Authorization: `Bearer ${auth.token}` },
213
+ };
214
+ }
215
+
216
+ export async function listSessionCheckpoints(sessionId, {
217
+ targetPath = process.cwd(),
218
+ limit = DEFAULT_CHECKPOINT_LIMIT,
219
+ resolveAuthSession = resolveActiveAuthSession,
220
+ request = requestJson,
221
+ } = {}) {
222
+ const normalizedSessionId = normalizeString(sessionId);
223
+ if (!normalizedSessionId) {
224
+ throw new Error("session id is required.");
225
+ }
226
+ const { apiUrl, headers } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
227
+ const params = new URLSearchParams({ limit: String(normalizeLimit(limit)) });
228
+ const response = await request(
229
+ `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/checkpoints?${params.toString()}`,
230
+ { method: "GET", headers },
231
+ );
232
+ const checkpoints = Array.isArray(response?.checkpoints) ? response.checkpoints : [];
233
+ return {
234
+ ok: true,
235
+ sessionId: normalizedSessionId,
236
+ apiUrl,
237
+ checkpoints,
238
+ count: Number(response?.count ?? checkpoints.length) || checkpoints.length,
239
+ };
240
+ }
241
+
242
+ export async function createSessionCheckpoint(sessionId, options = {}) {
243
+ const normalizedSessionId = normalizeString(sessionId);
244
+ const {
245
+ targetPath = process.cwd(),
246
+ resolveAuthSession = resolveActiveAuthSession,
247
+ requestMutation = requestJsonMutation,
248
+ } = options;
249
+ const { body, idempotencyKey } = buildManualCheckpointPayload(normalizedSessionId, options);
250
+ const { apiUrl, headers } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
251
+ const response = await requestMutation(
252
+ `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/checkpoints`,
253
+ {
254
+ operationName: "session-checkpoint-create",
255
+ headers,
256
+ body,
257
+ idempotencyKey,
258
+ },
259
+ );
260
+ return {
261
+ ...response,
262
+ sessionId: normalizedSessionId,
263
+ apiUrl,
264
+ checkpoint: response?.checkpoint || null,
265
+ idempotencyKey,
266
+ };
267
+ }
268
+
269
+ export async function generateSessionCheckpoint(sessionId, options = {}) {
270
+ const normalizedSessionId = normalizeString(sessionId);
271
+ const {
272
+ targetPath = process.cwd(),
273
+ resolveAuthSession = resolveActiveAuthSession,
274
+ requestMutation = requestJsonMutation,
275
+ } = options;
276
+ const { body, idempotencyKey } = buildGenerateCheckpointPayload(normalizedSessionId, options);
277
+ const { apiUrl, headers } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
278
+ const response = await requestMutation(
279
+ `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/checkpoints/generate`,
280
+ {
281
+ operationName: "session-checkpoint-generate",
282
+ headers,
283
+ body,
284
+ idempotencyKey,
285
+ },
286
+ );
287
+ return {
288
+ ...response,
289
+ sessionId: normalizedSessionId,
290
+ apiUrl,
291
+ checkpoint: response?.checkpoint || null,
292
+ idempotencyKey,
293
+ };
294
+ }