sentinelayer-cli 0.9.8 → 0.10.1

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.8",
3
+ "version": "0.10.1",
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/proxy.js CHANGED
@@ -27,7 +27,15 @@ const PROXY_RETRY_STATUSES = new Set([429, 502, 503, 504]);
27
27
  * @param {number} [options.temperature] - Temperature (default: 0.1)
28
28
  * @param {string} [options.apiUrl] - Override API URL
29
29
  * @param {string} [options.token] - Override Bearer token
30
- * @returns {Promise<{ text: string, usage: { inputTokens: number, outputTokens: number, costUsd: number, model: string, provider: string, latencyMs: number } }>}
30
+ * @param {string} [options.sessionId] - Optional Senti session id for server-side usage metering
31
+ * @param {string} [options.agentId] - Optional session agent id for server-side usage metering
32
+ * @param {string} [options.action] - Optional metered action, defaults server-side when omitted
33
+ * @param {string} [options.usageIdempotencyKey] - Stable per-intent key for proxy + ledger idempotency
34
+ * @param {string} [options.billingTier] - Optional billing tier hint
35
+ * @param {string} [options.customerPricingPolicy] - Optional customer pricing policy hint
36
+ * @param {object} [options.metadata] - Optional allowlisted billing metadata
37
+ * @param {Function} [options.fetchImpl] - Optional fetch implementation for tests
38
+ * @returns {Promise<{ text: string, usage: { inputTokens: number, outputTokens: number, costUsd: number, model: string, provider: string, latencyMs: number }, usageLedger: object | null }>}
31
39
  */
32
40
  export async function invokeViaProxy({
33
41
  prompt,
@@ -37,6 +45,14 @@ export async function invokeViaProxy({
37
45
  temperature = 0.1,
38
46
  apiUrl = "",
39
47
  token = "",
48
+ sessionId = "",
49
+ agentId = "",
50
+ action = "",
51
+ usageIdempotencyKey = "",
52
+ billingTier = "",
53
+ customerPricingPolicy = "",
54
+ metadata = null,
55
+ fetchImpl = fetch,
40
56
  } = {}) {
41
57
  // Resolve credentials from session if not provided
42
58
  let resolvedApiUrl = String(apiUrl || "").trim();
@@ -59,13 +75,40 @@ export async function invokeViaProxy({
59
75
 
60
76
  const url = `${resolvedApiUrl.replace(/\/+$/, "")}/api/v1/proxy/llm`;
61
77
 
62
- const body = JSON.stringify({
78
+ const requestBody = {
63
79
  model,
64
80
  system_prompt: systemPrompt || "You are a code reviewer.",
65
81
  user_content: String(prompt || ""),
66
82
  max_tokens: maxTokens,
67
83
  temperature,
68
- });
84
+ };
85
+
86
+ const normalizedSessionId = String(sessionId || "").trim();
87
+ const normalizedAgentId = String(agentId || "").trim();
88
+ const normalizedAction = String(action || "").trim();
89
+ const normalizedUsageIdempotencyKey = String(usageIdempotencyKey || "").trim();
90
+ const normalizedBillingTier = String(billingTier || "").trim();
91
+ const normalizedCustomerPricingPolicy = String(customerPricingPolicy || "").trim();
92
+ if (normalizedSessionId) requestBody.session_id = normalizedSessionId;
93
+ if (normalizedAgentId) requestBody.agent_id = normalizedAgentId;
94
+ if (normalizedAction) requestBody.action = normalizedAction;
95
+ if (normalizedUsageIdempotencyKey) requestBody.usage_idempotency_key = normalizedUsageIdempotencyKey;
96
+ if (normalizedBillingTier) requestBody.billing_tier = normalizedBillingTier;
97
+ if (normalizedCustomerPricingPolicy) requestBody.customer_pricing_policy = normalizedCustomerPricingPolicy;
98
+ if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
99
+ requestBody.metadata = metadata;
100
+ }
101
+
102
+ const headers = {
103
+ "Content-Type": "application/json",
104
+ Authorization: `Bearer ${resolvedToken}`,
105
+ Accept: "application/json",
106
+ };
107
+ if (normalizedUsageIdempotencyKey) {
108
+ headers["Idempotency-Key"] = normalizedUsageIdempotencyKey;
109
+ }
110
+
111
+ const body = JSON.stringify(requestBody);
69
112
 
70
113
  let response = null;
71
114
  let lastError = null;
@@ -75,13 +118,9 @@ export async function invokeViaProxy({
75
118
  const controller = new AbortController();
76
119
  const timeoutHandle = setTimeout(() => controller.abort(), PROXY_TIMEOUT_MS);
77
120
  try {
78
- response = await fetch(url, {
121
+ response = await fetchImpl(url, {
79
122
  method: "POST",
80
- headers: {
81
- "Content-Type": "application/json",
82
- Authorization: `Bearer ${resolvedToken}`,
83
- Accept: "application/json",
84
- },
123
+ headers,
85
124
  body,
86
125
  signal: controller.signal,
87
126
  });
@@ -131,6 +170,7 @@ export async function invokeViaProxy({
131
170
  provider: result.usage?.provider || "sentinelayer",
132
171
  latencyMs: result.usage?.latency_ms || 0,
133
172
  },
173
+ usageLedger: result.usageLedger || result.usage_ledger || null,
134
174
  };
135
175
  }
136
176
 
@@ -347,14 +347,37 @@ async function ensureLocalSessionForRemoteCommand(
347
347
  }
348
348
  const existingStatus = normalizeString(existing.status).toLowerCase();
349
349
  const locallyClosedByStatus = existingStatus === "expired" || existingStatus === "archived";
350
+ let verifiedRemoteSession = remoteSession;
351
+ let verifiedRemoteStatus = normalizeString(
352
+ verifiedRemoteSession?.archiveStatus || verifiedRemoteSession?.status,
353
+ ).toLowerCase();
350
354
  if (locallyClosedByStatus && !skipRemoteProbe) {
355
+ const verification = await verifyRemoteSession(sessionId, { targetPath }).catch((error) => ({
356
+ ok: false,
357
+ reason: normalizeString(error?.message) || "verify_failed",
358
+ }));
359
+ if (!verification?.ok) {
360
+ throw new Error(
361
+ `Session '${sessionId}' is ${existingStatus} locally and remote verification failed (${verification?.reason || "unknown"}).`,
362
+ );
363
+ }
364
+ verifiedRemoteSession = verification.session || verifiedRemoteSession;
365
+ verifiedRemoteStatus = normalizeString(
366
+ verifiedRemoteSession?.archiveStatus || verifiedRemoteSession?.status,
367
+ ).toLowerCase();
368
+ }
369
+ if (
370
+ locallyClosedByStatus &&
371
+ verifiedRemoteStatus &&
372
+ !["active", "pending"].includes(verifiedRemoteStatus)
373
+ ) {
351
374
  throw new Error(
352
- `Session '${sessionId}' is ${existingStatus} locally; run \`sl session join ${sessionId}\` to verify remote access before posting.`,
375
+ `Session '${sessionId}' is ${existingStatus} locally and remote status is ${verifiedRemoteStatus}; refusing to reopen a closed session.`,
353
376
  );
354
377
  }
355
378
 
356
- let access = { accessible: Boolean(skipRemoteProbe), reason: "" };
357
- if (!skipRemoteProbe) {
379
+ let access = { accessible: Boolean(skipRemoteProbe || locallyClosedByStatus), reason: "" };
380
+ if (!skipRemoteProbe && !locallyClosedByStatus) {
358
381
  access = await probeSessionAccess(sessionId, { targetPath }).catch((error) => ({
359
382
  accessible: false,
360
383
  reason: normalizeString(error?.message) || "probe_failed",
@@ -368,12 +391,13 @@ async function ensureLocalSessionForRemoteCommand(
368
391
 
369
392
  const refreshed = await refreshSessionCacheForRemoteActivity(sessionId, {
370
393
  targetPath,
371
- title,
394
+ title: title || normalizeString(verifiedRemoteSession?.title),
395
+ expiresAt: normalizeString(verifiedRemoteSession?.expiresAt),
372
396
  lastInteractionAt:
373
- normalizeString(remoteSession?.lastInteractionAt) ||
374
- normalizeString(remoteSession?.lastActivityAt) ||
375
- normalizeString(remoteSession?.updatedAt) ||
376
- normalizeString(remoteSession?.createdAt),
397
+ normalizeString(verifiedRemoteSession?.lastInteractionAt) ||
398
+ normalizeString(verifiedRemoteSession?.lastActivityAt) ||
399
+ normalizeString(verifiedRemoteSession?.updatedAt) ||
400
+ normalizeString(verifiedRemoteSession?.createdAt),
377
401
  });
378
402
  return { materialized: false, refreshed: Boolean(refreshed), session: refreshed || existing };
379
403
  }
@@ -396,6 +420,13 @@ async function ensureLocalSessionForRemoteCommand(
396
420
  targetPath,
397
421
  sessionId,
398
422
  title: normalizeString(title) || `remote-${String(sessionId).slice(0, 8)}`,
423
+ createdAt: normalizeString(remoteSession?.createdAt),
424
+ expiresAt: normalizeString(remoteSession?.expiresAt),
425
+ lastInteractionAt:
426
+ normalizeString(remoteSession?.lastInteractionAt) ||
427
+ normalizeString(remoteSession?.lastActivityAt) ||
428
+ normalizeString(remoteSession?.updatedAt) ||
429
+ normalizeString(remoteSession?.createdAt),
399
430
  });
400
431
  return { materialized: true, refreshed: false, session: created };
401
432
  }
@@ -453,6 +453,10 @@ async function buildHelpResponseMessage(
453
453
  normalizePositiveInteger(daemonState.helpRequestTimeoutMs, HELP_REQUEST_TIMEOUT_MS) * 2
454
454
  )
455
455
  );
456
+ const requestCorrelationId =
457
+ normalizeString(requestEvent?.payload?.requestId) ||
458
+ normalizeString(requestEvent?.requestId) ||
459
+ normalizeIsoTimestamp(requestEvent?.ts, daemonState.startedAt);
456
460
 
457
461
  try {
458
462
  const llmResult = await runWithTimeout(
@@ -463,6 +467,15 @@ async function buildHelpResponseMessage(
463
467
  prompt: userPrompt,
464
468
  maxTokens: 320,
465
469
  temperature: 0.1,
470
+ sessionId: daemonState.sessionId,
471
+ agentId: SENTI_IDENTITY.id,
472
+ action: "proxy_llm",
473
+ usageIdempotencyKey: `senti:${daemonState.sessionId}:help:${requestCorrelationId}`,
474
+ billingTier: "internal",
475
+ metadata: {
476
+ purpose: "senti_help_response",
477
+ runId: requestCorrelationId,
478
+ },
466
479
  })
467
480
  ),
468
481
  llmTimeoutMs,
@@ -537,6 +537,7 @@ export async function refreshSessionCacheForRemoteActivity(
537
537
  targetPath = process.cwd(),
538
538
  title = "",
539
539
  ttlSeconds = DEFAULT_TTL_SECONDS,
540
+ expiresAt = "",
540
541
  lastInteractionAt = "",
541
542
  nowIso = new Date().toISOString(),
542
543
  } = {}
@@ -563,7 +564,7 @@ export async function refreshSessionCacheForRemoteActivity(
563
564
  const metadata = {
564
565
  ...loaded.metadata,
565
566
  updatedAt: nowIso,
566
- expiresAt: toIsoAfterSeconds(nowIso, normalizedTtlSeconds),
567
+ expiresAt: normalizeIsoTimestamp(expiresAt, toIsoAfterSeconds(nowIso, normalizedTtlSeconds)),
567
568
  ttlSeconds: normalizedTtlSeconds,
568
569
  status: SESSION_STATUS_ACTIVE,
569
570
  expiredAt: null,
@@ -6,13 +6,15 @@ import { createAgentEvent, normalizeAgentEvent } from "../events/schema.js";
6
6
  import { enrichEventWithMentions } from "./mentions.js";
7
7
  import { resolveSessionPaths } from "./paths.js";
8
8
  import { redactEventPayload } from "./redact.js";
9
- import { syncSessionEventToApi } from "./sync.js";
9
+ import { fetchSessionFromApi, listSessionsFromApi, syncSessionEventToApi } from "./sync.js";
10
10
 
11
11
  const DEFAULT_POLL_MS = 500;
12
12
  const DEFAULT_LOCK_TIMEOUT_MS = 10_000;
13
13
  const DEFAULT_LOCK_STALE_MS = 30_000;
14
14
  const DEFAULT_LOCK_POLL_MS = 25;
15
15
  const DEFAULT_MAX_STREAM_EVENTS = 10_000;
16
+ const DEFAULT_REMOTE_REFRESH_TTL_SECONDS = 24 * 60 * 60;
17
+ const REMOTE_REFRESH_TIMEOUT_MS = 2_500;
16
18
 
17
19
  function normalizeString(value) {
18
20
  return String(value || "").trim();
@@ -41,6 +43,15 @@ function normalizePositiveInteger(value, fallbackValue) {
41
43
  return Math.floor(normalized);
42
44
  }
43
45
 
46
+ function toIsoAfterSeconds(baseIso, seconds) {
47
+ const baseEpoch = Date.parse(normalizeIsoTimestamp(baseIso, new Date().toISOString()));
48
+ const normalizedSeconds = normalizePositiveInteger(
49
+ seconds,
50
+ DEFAULT_REMOTE_REFRESH_TTL_SECONDS,
51
+ );
52
+ return new Date(baseEpoch + normalizedSeconds * 1000).toISOString();
53
+ }
54
+
44
55
  async function readSessionMetadata(paths) {
45
56
  try {
46
57
  const raw = await fsp.readFile(paths.metadataPath, "utf-8");
@@ -64,6 +75,108 @@ async function writeSessionMetadata(paths, metadata = {}) {
64
75
  await fsp.rename(tmpPath, paths.metadataPath);
65
76
  }
66
77
 
78
+ function remoteStatusAllowsLocalRefresh(remoteSession = {}) {
79
+ const status = normalizeString(remoteSession.archiveStatus || remoteSession.status).toLowerCase();
80
+ return !status || status === "active" || status === "pending";
81
+ }
82
+
83
+ function resolveRemoteSessionStatus(remoteSession = {}) {
84
+ return normalizeString(remoteSession.archiveStatus || remoteSession.status).toLowerCase();
85
+ }
86
+
87
+ async function resolveRemoteRefreshCandidate(sessionId, { targetPath = process.cwd() } = {}) {
88
+ const singletonResult = await fetchSessionFromApi(sessionId, {
89
+ targetPath,
90
+ timeoutMs: REMOTE_REFRESH_TIMEOUT_MS,
91
+ }).catch((error) => ({
92
+ ok: false,
93
+ reason: normalizeString(error?.message) || "fetch_failed",
94
+ session: null,
95
+ }));
96
+ if (singletonResult?.ok && singletonResult.session) {
97
+ if (!remoteStatusAllowsLocalRefresh(singletonResult.session)) {
98
+ return {
99
+ ok: false,
100
+ reason: `remote_${resolveRemoteSessionStatus(singletonResult.session) || "closed"}`,
101
+ remoteSession: singletonResult.session,
102
+ };
103
+ }
104
+ return { ok: true, reason: "", remoteSession: singletonResult.session };
105
+ }
106
+ if (singletonResult?.status && singletonResult.status !== 404) {
107
+ return { ok: false, reason: singletonResult.reason || `api_${singletonResult.status}` };
108
+ }
109
+
110
+ const listResult = await listSessionsFromApi({
111
+ targetPath,
112
+ includeArchived: true,
113
+ limit: 200,
114
+ timeoutMs: REMOTE_REFRESH_TIMEOUT_MS,
115
+ }).catch((error) => ({
116
+ ok: false,
117
+ reason: normalizeString(error?.message) || "list_failed",
118
+ sessions: [],
119
+ }));
120
+ if (listResult?.ok) {
121
+ const remoteSession = (listResult.sessions || []).find(
122
+ (entry) => normalizeString(entry?.sessionId) === sessionId,
123
+ );
124
+ if (remoteSession) {
125
+ if (!remoteStatusAllowsLocalRefresh(remoteSession)) {
126
+ return {
127
+ ok: false,
128
+ reason: `remote_${resolveRemoteSessionStatus(remoteSession) || "closed"}`,
129
+ remoteSession,
130
+ };
131
+ }
132
+ return { ok: true, reason: "", remoteSession };
133
+ }
134
+ }
135
+
136
+ return { ok: false, reason: listResult?.reason || singletonResult?.reason || "remote_unavailable" };
137
+ }
138
+
139
+ async function refreshExpiredMetadataFromRemote(paths, metadata = {}, { targetPath = process.cwd() } = {}) {
140
+ const candidate = await resolveRemoteRefreshCandidate(paths.sessionId, { targetPath });
141
+ if (!candidate?.ok) {
142
+ return { refreshed: false, reason: candidate?.reason || "remote_unavailable", metadata };
143
+ }
144
+ const remoteSession = candidate.remoteSession || {};
145
+ const nowIso = new Date().toISOString();
146
+ const ttlSeconds = normalizePositiveInteger(
147
+ metadata.ttlSeconds,
148
+ DEFAULT_REMOTE_REFRESH_TTL_SECONDS,
149
+ );
150
+ const remoteExpiresAt = normalizeString(remoteSession.expiresAt);
151
+ const remoteExpiryEpoch = Date.parse(remoteExpiresAt);
152
+ const nowEpoch = Date.parse(nowIso);
153
+ const expiresAt =
154
+ Number.isFinite(remoteExpiryEpoch) && Number.isFinite(nowEpoch) && remoteExpiryEpoch > nowEpoch
155
+ ? new Date(remoteExpiryEpoch).toISOString()
156
+ : toIsoAfterSeconds(nowIso, ttlSeconds);
157
+ const refreshed = {
158
+ ...metadata,
159
+ updatedAt: nowIso,
160
+ expiresAt,
161
+ ttlSeconds,
162
+ status: "active",
163
+ expiredAt: null,
164
+ archivedAt: null,
165
+ archiveStatus: resolveRemoteSessionStatus(remoteSession) || "active",
166
+ title: normalizeString(remoteSession.title) || metadata.title || null,
167
+ lastInteractionAt: normalizeIsoTimestamp(
168
+ remoteSession.lastInteractionAt ||
169
+ remoteSession.lastActivityAt ||
170
+ remoteSession.updatedAt ||
171
+ remoteSession.createdAt ||
172
+ metadata.lastInteractionAt,
173
+ metadata.lastInteractionAt || metadata.createdAt || nowIso,
174
+ ),
175
+ };
176
+ await writeSessionMetadata(paths, refreshed);
177
+ return { refreshed: true, reason: "", metadata: refreshed };
178
+ }
179
+
67
180
  function isSessionExpired(metadata = {}, nowIso = new Date().toISOString()) {
68
181
  const status = normalizeString(metadata.status).toLowerCase();
69
182
  if (status === "expired" || status === "archived") {
@@ -227,12 +340,18 @@ export async function appendToStream(
227
340
  { targetPath = process.cwd(), maxEvents = DEFAULT_MAX_STREAM_EVENTS, syncRemote = true } = {}
228
341
  ) {
229
342
  const paths = resolveSessionPaths(sessionId, { targetPath });
230
- const metadata = await readSessionMetadata(paths);
343
+ let metadata = await readSessionMetadata(paths);
231
344
  if (!metadata) {
232
345
  throw new Error(`Session '${paths.sessionId}' was not found.`);
233
346
  }
234
347
  if (isSessionExpired(metadata)) {
235
- throw new Error(`Session '${paths.sessionId}' is expired and does not accept new events.`);
348
+ const refresh = await refreshExpiredMetadataFromRemote(paths, metadata, { targetPath });
349
+ if (!refresh.refreshed) {
350
+ throw new Error(
351
+ `Session '${paths.sessionId}' is expired and does not accept new events (remote refresh failed: ${refresh.reason}).`,
352
+ );
353
+ }
354
+ metadata = refresh.metadata;
236
355
  }
237
356
 
238
357
  const rawEvent = materializeCanonicalEvent(paths.sessionId, event);
@@ -1319,6 +1319,91 @@ export async function listSessionsFromApi({
1319
1319
  };
1320
1320
  }
1321
1321
 
1322
+ /**
1323
+ * Fetch one visible session row by id via `GET /api/v1/sessions/{id}`.
1324
+ *
1325
+ * The session-events read probe can prove membership, but it cannot prove the
1326
+ * remote session is still active. Callers that need to refresh local closed
1327
+ * metadata should use this status-bearing endpoint first, then fall back to
1328
+ * `listSessionsFromApi` only for older API deployments without singleton read.
1329
+ *
1330
+ * @param {string} sessionId
1331
+ * @param {object} [options]
1332
+ * @param {string} [options.targetPath]
1333
+ * @param {Function} [options.resolveAuthSession]
1334
+ * @param {Function} [options.fetchImpl]
1335
+ * @param {number} [options.timeoutMs]
1336
+ * @returns {Promise<{ok: boolean, reason: string, session: object|null, status?: number}>}
1337
+ */
1338
+ export async function fetchSessionFromApi(
1339
+ sessionId,
1340
+ {
1341
+ targetPath = process.cwd(),
1342
+ resolveAuthSession = resolveActiveAuthSession,
1343
+ fetchImpl = fetchWithTimeout,
1344
+ timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
1345
+ } = {},
1346
+ ) {
1347
+ const normalizedSessionId = normalizeString(sessionId);
1348
+ if (!normalizedSessionId) {
1349
+ return { ok: false, reason: "invalid_session_id", session: null };
1350
+ }
1351
+
1352
+ let session;
1353
+ try {
1354
+ session = await resolveAuthSession({
1355
+ cwd: targetPath,
1356
+ env: process.env,
1357
+ autoRotate: false,
1358
+ });
1359
+ } catch {
1360
+ return { ok: false, reason: "no_session", session: null };
1361
+ }
1362
+ if (!session || !session.token) {
1363
+ return { ok: false, reason: "not_authenticated", session: null, status: 401 };
1364
+ }
1365
+
1366
+ const apiBaseUrl = resolveApiBaseUrl(session);
1367
+ const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
1368
+
1369
+ let response;
1370
+ try {
1371
+ response = await fetchImpl(
1372
+ endpoint,
1373
+ {
1374
+ method: "GET",
1375
+ headers: { Authorization: `Bearer ${session.token}` },
1376
+ },
1377
+ normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
1378
+ );
1379
+ } catch (err) {
1380
+ return {
1381
+ ok: false,
1382
+ reason: normalizeString(err?.message) || "fetch_failed",
1383
+ session: null,
1384
+ };
1385
+ }
1386
+ if (!response) {
1387
+ return { ok: false, reason: "no_response", session: null };
1388
+ }
1389
+ if (response.ok) {
1390
+ const body = await response.json().catch(() => ({}));
1391
+ const sessionPayload = body && body.session && typeof body.session === "object"
1392
+ ? body.session
1393
+ : body && typeof body === "object"
1394
+ ? body
1395
+ : null;
1396
+ return { ok: true, reason: "", session: sessionPayload, status: response.status };
1397
+ }
1398
+ if (response.status === 403) {
1399
+ return { ok: false, reason: "not_a_member", session: null, status: 403 };
1400
+ }
1401
+ if (response.status === 404) {
1402
+ return { ok: false, reason: "session_not_found", session: null, status: 404 };
1403
+ }
1404
+ return { ok: false, reason: `api_${response.status}`, session: null, status: response.status };
1405
+ }
1406
+
1322
1407
  /**
1323
1408
  * Probe whether a single session is visible to the active user.
1324
1409
  *