@starcite/sdk 0.0.3 → 0.0.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/dist/index.js CHANGED
@@ -1,6 +1,106 @@
1
- // src/client.ts
1
+ // src/auth.ts
2
+ import { decodeJwt } from "jose";
2
3
  import { z as z2 } from "zod";
3
4
 
5
+ // src/identity.ts
6
+ import { z } from "zod";
7
+ var AGENT_PREFIX = "agent:";
8
+ var USER_PREFIX = "user:";
9
+ var PrincipalTypeSchema = z.enum(["user", "agent"]);
10
+ var SessionCreatorPrincipalSchema = z.object({
11
+ tenant_id: z.string().min(1),
12
+ id: z.string().min(1),
13
+ type: PrincipalTypeSchema
14
+ });
15
+ var SessionTokenPrincipalSchema = z.object({
16
+ type: PrincipalTypeSchema,
17
+ id: z.string().min(1)
18
+ });
19
+ var StarciteIdentity = class {
20
+ tenantId;
21
+ id;
22
+ type;
23
+ constructor(options) {
24
+ this.tenantId = options.tenantId;
25
+ this.id = options.id;
26
+ this.type = options.type;
27
+ }
28
+ /**
29
+ * Serializes to the `creator_principal` wire format expected by the API.
30
+ */
31
+ toCreatorPrincipal() {
32
+ return { tenant_id: this.tenantId, id: this.id, type: this.type };
33
+ }
34
+ /**
35
+ * Serializes to the `principal` wire format used in session token requests.
36
+ */
37
+ toTokenPrincipal() {
38
+ return { id: this.id, type: this.type };
39
+ }
40
+ /**
41
+ * Returns the actor string derived from this identity (e.g. `agent:planner`, `user:alice`).
42
+ */
43
+ toActor() {
44
+ if (this.id.startsWith(AGENT_PREFIX) || this.id.startsWith(USER_PREFIX)) {
45
+ return this.id;
46
+ }
47
+ return `${this.type}:${this.id}`;
48
+ }
49
+ };
50
+ function agentFromActor(actor) {
51
+ return actor.startsWith(AGENT_PREFIX) ? actor.slice(AGENT_PREFIX.length) : void 0;
52
+ }
53
+
54
+ // src/auth.ts
55
+ var ApiKeyClaimsSchema = z2.object({
56
+ iss: z2.string().min(1).optional(),
57
+ sub: z2.string().min(1).optional(),
58
+ tenant_id: z2.string().min(1).optional(),
59
+ principal_id: z2.string().min(1).optional(),
60
+ principal_type: PrincipalTypeSchema.optional()
61
+ });
62
+ var SessionTokenClaimsSchema = z2.object({
63
+ session_id: z2.string().min(1).optional(),
64
+ sub: z2.string().min(1).optional(),
65
+ tenant_id: z2.string().min(1),
66
+ principal_id: z2.string().min(1).optional(),
67
+ principal_type: PrincipalTypeSchema.optional()
68
+ });
69
+ function inferIssuerAuthorityFromApiKey(apiKey) {
70
+ const claims = ApiKeyClaimsSchema.parse(decodeJwt(apiKey));
71
+ if (!claims.iss) {
72
+ return void 0;
73
+ }
74
+ const url = new URL(claims.iss);
75
+ return url.origin;
76
+ }
77
+ function inferIdentityFromApiKey(apiKey) {
78
+ const claims = ApiKeyClaimsSchema.parse(decodeJwt(apiKey));
79
+ const id = claims.principal_id ?? claims.sub;
80
+ const tenantId = claims.tenant_id;
81
+ if (!(tenantId && id && claims.principal_type)) {
82
+ return void 0;
83
+ }
84
+ return new StarciteIdentity({
85
+ tenantId,
86
+ id,
87
+ type: claims.principal_type
88
+ });
89
+ }
90
+ function decodeSessionToken(token) {
91
+ const claims = SessionTokenClaimsSchema.parse(decodeJwt(token));
92
+ const principalId = claims.principal_id ?? claims.sub ?? "session-user";
93
+ const principalType = claims.principal_type ?? "user";
94
+ return {
95
+ sessionId: claims.session_id,
96
+ identity: new StarciteIdentity({
97
+ tenantId: claims.tenant_id,
98
+ id: principalId,
99
+ type: principalType
100
+ })
101
+ };
102
+ }
103
+
4
104
  // src/errors.ts
5
105
  var StarciteError = class extends Error {
6
106
  constructor(message) {
@@ -29,797 +129,1674 @@ var StarciteConnectionError = class extends StarciteError {
29
129
  this.name = "StarciteConnectionError";
30
130
  }
31
131
  };
132
+ var StarciteTailError = class extends StarciteConnectionError {
133
+ /** Session id tied to this tail stream. */
134
+ sessionId;
135
+ /** Failure stage in the tail lifecycle. */
136
+ stage;
137
+ /** Reconnect attempts observed before failing. */
138
+ attempts;
139
+ /** WebSocket close code when available. */
140
+ closeCode;
141
+ /** WebSocket close reason when available. */
142
+ closeReason;
143
+ constructor(message, options) {
144
+ super(message);
145
+ this.name = "StarciteTailError";
146
+ this.sessionId = options.sessionId;
147
+ this.stage = options.stage;
148
+ this.attempts = options.attempts ?? 0;
149
+ this.closeCode = options.closeCode;
150
+ this.closeReason = options.closeReason;
151
+ }
152
+ };
153
+ var StarciteTokenExpiredError = class extends StarciteTailError {
154
+ constructor(message, options) {
155
+ super(message, { ...options, stage: "stream" });
156
+ this.name = "StarciteTokenExpiredError";
157
+ }
158
+ };
159
+ var StarciteBackpressureError = class extends StarciteTailError {
160
+ constructor(message, options) {
161
+ super(message, { ...options, stage: "consumer_backpressure" });
162
+ this.name = "StarciteBackpressureError";
163
+ }
164
+ };
165
+ var StarciteRetryLimitError = class extends StarciteTailError {
166
+ constructor(message, options) {
167
+ super(message, { ...options, stage: "retry_limit" });
168
+ this.name = "StarciteRetryLimitError";
169
+ }
170
+ };
171
+
172
+ // src/session.ts
173
+ import EventEmitter3 from "eventemitter3";
174
+
175
+ // src/session-log.ts
176
+ import EventEmitter from "eventemitter3";
177
+ var SessionLogGapError = class extends StarciteError {
178
+ constructor(message) {
179
+ super(message);
180
+ this.name = "SessionLogGapError";
181
+ }
182
+ };
183
+ var SessionLogConflictError = class extends StarciteError {
184
+ constructor(message) {
185
+ super(message);
186
+ this.name = "SessionLogConflictError";
187
+ }
188
+ };
189
+ var SessionLog = class {
190
+ history = [];
191
+ emitter = new EventEmitter();
192
+ canonicalBySeq = /* @__PURE__ */ new Map();
193
+ maxEvents;
194
+ appliedSeq = 0;
195
+ constructor(options = {}) {
196
+ this.setMaxEvents(options.maxEvents);
197
+ }
198
+ setMaxEvents(maxEvents) {
199
+ if (maxEvents !== void 0 && (!Number.isInteger(maxEvents) || maxEvents <= 0)) {
200
+ throw new StarciteError(
201
+ "Session log maxEvents must be a positive integer"
202
+ );
203
+ }
204
+ this.maxEvents = maxEvents;
205
+ this.enforceRetention();
206
+ }
207
+ applyBatch(batch) {
208
+ const applied = [];
209
+ for (const event of batch) {
210
+ if (this.apply(event)) {
211
+ applied.push(event);
212
+ this.emitter.emit("event", event);
213
+ }
214
+ }
215
+ return applied;
216
+ }
217
+ hydrate(state) {
218
+ if (!Number.isInteger(state.cursor) || state.cursor < 0) {
219
+ throw new StarciteError(
220
+ "Session store cursor must be a non-negative integer"
221
+ );
222
+ }
223
+ this.history.length = 0;
224
+ this.canonicalBySeq.clear();
225
+ this.appliedSeq = state.cursor;
226
+ let previousSeq;
227
+ for (const event of state.events) {
228
+ if (event.seq > state.cursor) {
229
+ throw new StarciteError(
230
+ `Session store contains event seq ${event.seq} above cursor ${state.cursor}`
231
+ );
232
+ }
233
+ if (previousSeq !== void 0 && event.seq !== previousSeq + 1) {
234
+ throw new StarciteError(
235
+ `Session store events must be contiguous; saw seq ${event.seq} after ${previousSeq}`
236
+ );
237
+ }
238
+ this.history.push(event);
239
+ this.canonicalBySeq.set(event.seq, JSON.stringify(event));
240
+ previousSeq = event.seq;
241
+ }
242
+ this.enforceRetention();
243
+ }
244
+ subscribe(listener, options = {}) {
245
+ const shouldReplay = options.replay ?? true;
246
+ if (shouldReplay) {
247
+ for (const event of this.history) {
248
+ listener(event);
249
+ }
250
+ }
251
+ this.emitter.on("event", listener);
252
+ return () => {
253
+ this.emitter.off("event", listener);
254
+ };
255
+ }
256
+ apply(event) {
257
+ const existingCanonical = this.canonicalBySeq.get(event.seq);
258
+ if (event.seq <= this.appliedSeq) {
259
+ const incomingCanonical = JSON.stringify(event);
260
+ if (!existingCanonical) {
261
+ const oldestRetainedSeq = this.history[0]?.seq;
262
+ if (oldestRetainedSeq === void 0 || event.seq < oldestRetainedSeq) {
263
+ return false;
264
+ }
265
+ throw new SessionLogConflictError(
266
+ `Session log has no canonical payload for retained seq ${event.seq}`
267
+ );
268
+ }
269
+ if (incomingCanonical !== existingCanonical) {
270
+ throw new SessionLogConflictError(
271
+ `Session log conflict for seq ${event.seq}: received different payload for an already-applied event`
272
+ );
273
+ }
274
+ return false;
275
+ }
276
+ const expectedSeq = this.appliedSeq + 1;
277
+ if (event.seq !== expectedSeq) {
278
+ throw new SessionLogGapError(
279
+ `Session log gap detected: expected seq ${expectedSeq} but received ${event.seq}`
280
+ );
281
+ }
282
+ this.history.push(event);
283
+ this.canonicalBySeq.set(event.seq, JSON.stringify(event));
284
+ this.appliedSeq = event.seq;
285
+ this.enforceRetention();
286
+ return true;
287
+ }
288
+ getSnapshot(syncing) {
289
+ return {
290
+ events: this.history.slice(),
291
+ lastSeq: this.appliedSeq,
292
+ syncing
293
+ };
294
+ }
295
+ get events() {
296
+ return this.history.slice();
297
+ }
298
+ get cursor() {
299
+ return this.appliedSeq;
300
+ }
301
+ get lastSeq() {
302
+ return this.appliedSeq;
303
+ }
304
+ enforceRetention() {
305
+ if (this.maxEvents === void 0) {
306
+ return;
307
+ }
308
+ while (this.history.length > this.maxEvents) {
309
+ const removed = this.history.shift();
310
+ if (!removed) {
311
+ return;
312
+ }
313
+ this.canonicalBySeq.delete(removed.seq);
314
+ }
315
+ }
316
+ };
317
+
318
+ // src/tail/frame.ts
319
+ import { z as z4 } from "zod";
32
320
 
33
321
  // src/types.ts
34
- import { z } from "zod";
35
- var ArbitraryObjectSchema = z.record(z.unknown());
36
- var CreatorTypeSchema = z.union([z.literal("user"), z.literal("agent")]);
37
- var SessionCreatorPrincipalSchema = z.object({
38
- tenant_id: z.string().min(1),
39
- id: z.string().min(1),
40
- type: CreatorTypeSchema
322
+ import { z as z3 } from "zod";
323
+ var SessionCreatorPrincipalSchema2 = SessionCreatorPrincipalSchema;
324
+ var SessionTokenPrincipalSchema2 = SessionTokenPrincipalSchema;
325
+ var ArbitraryObjectSchema = z3.record(z3.unknown());
326
+ var SessionTokenScopeSchema = z3.enum(["session:read", "session:append"]);
327
+ var IssueSessionTokenInputSchema = z3.object({
328
+ session_id: z3.string().min(1),
329
+ principal: SessionTokenPrincipalSchema2,
330
+ scopes: z3.array(SessionTokenScopeSchema).min(1),
331
+ ttl_seconds: z3.number().int().positive().max(24 * 60 * 60).optional()
41
332
  });
42
- var CreateSessionInputSchema = z.object({
43
- id: z.string().optional(),
44
- title: z.string().optional(),
333
+ var IssueSessionTokenResponseSchema = z3.object({
334
+ token: z3.string().min(1),
335
+ expires_in: z3.number().int().positive()
336
+ });
337
+ var CreateSessionInputSchema = z3.object({
338
+ id: z3.string().optional(),
339
+ title: z3.string().optional(),
45
340
  metadata: ArbitraryObjectSchema.optional(),
46
- creator_principal: SessionCreatorPrincipalSchema.optional()
341
+ creator_principal: SessionCreatorPrincipalSchema2.optional()
47
342
  });
48
- var SessionRecordSchema = z.object({
49
- id: z.string(),
50
- title: z.string().nullable().optional(),
343
+ var SessionRecordSchema = z3.object({
344
+ id: z3.string(),
345
+ title: z3.string().nullable().optional(),
51
346
  metadata: ArbitraryObjectSchema,
52
- last_seq: z.number().int().nonnegative(),
53
- created_at: z.string(),
54
- updated_at: z.string()
347
+ last_seq: z3.number().int().nonnegative(),
348
+ created_at: z3.string(),
349
+ updated_at: z3.string()
55
350
  });
56
- var SessionListItemSchema = z.object({
57
- id: z.string(),
58
- title: z.string().nullable().optional(),
351
+ var SessionListItemSchema = z3.object({
352
+ id: z3.string(),
353
+ title: z3.string().nullable().optional(),
59
354
  metadata: ArbitraryObjectSchema,
60
- created_at: z.string()
355
+ created_at: z3.string()
61
356
  });
62
- var SessionListPageSchema = z.object({
63
- sessions: z.array(SessionListItemSchema),
64
- next_cursor: z.string().nullable()
357
+ var SessionListPageSchema = z3.object({
358
+ sessions: z3.array(SessionListItemSchema),
359
+ next_cursor: z3.string().nullable()
65
360
  });
66
- var AppendEventRequestSchema = z.object({
67
- type: z.string().min(1),
361
+ var AppendEventRequestSchema = z3.object({
362
+ type: z3.string().min(1),
68
363
  payload: ArbitraryObjectSchema,
69
- actor: z.string().min(1),
70
- producer_id: z.string().min(1),
71
- producer_seq: z.number().int().positive(),
72
- source: z.string().optional(),
364
+ actor: z3.string().min(1).optional(),
365
+ producer_id: z3.string().min(1),
366
+ producer_seq: z3.number().int().positive(),
367
+ source: z3.string().optional(),
73
368
  metadata: ArbitraryObjectSchema.optional(),
74
369
  refs: ArbitraryObjectSchema.optional(),
75
- idempotency_key: z.string().optional(),
76
- expected_seq: z.number().int().nonnegative().optional()
370
+ idempotency_key: z3.string().optional(),
371
+ expected_seq: z3.number().int().nonnegative().optional()
77
372
  });
78
- var AppendEventResponseSchema = z.object({
79
- seq: z.number().int().nonnegative(),
80
- last_seq: z.number().int().nonnegative(),
81
- deduped: z.boolean()
373
+ var AppendEventResponseSchema = z3.object({
374
+ seq: z3.number().int().nonnegative(),
375
+ last_seq: z3.number().int().nonnegative(),
376
+ deduped: z3.boolean()
82
377
  });
83
- var TailEventSchema = z.object({
84
- seq: z.number().int().nonnegative(),
85
- type: z.string().min(1),
378
+ var TailEventSchema = z3.object({
379
+ seq: z3.number().int().nonnegative(),
380
+ type: z3.string().min(1),
86
381
  payload: ArbitraryObjectSchema,
87
- actor: z.string().min(1),
88
- producer_id: z.string().min(1),
89
- producer_seq: z.number().int().positive(),
90
- source: z.string().optional(),
382
+ actor: z3.string().min(1),
383
+ producer_id: z3.string().min(1),
384
+ producer_seq: z3.number().int().positive(),
385
+ source: z3.string().optional(),
91
386
  metadata: ArbitraryObjectSchema.optional(),
92
387
  refs: ArbitraryObjectSchema.optional(),
93
- idempotency_key: z.string().nullable().optional(),
94
- inserted_at: z.string().optional()
95
- });
96
- var SessionEventInternalSchema = TailEventSchema.extend({
97
- agent: z.string().optional(),
98
- text: z.string().optional()
388
+ idempotency_key: z3.string().nullable().optional(),
389
+ inserted_at: z3.string().optional()
99
390
  });
100
- var SessionAppendInputSchema = z.object({
101
- agent: z.string().trim().min(1),
102
- producerId: z.string().trim().min(1),
103
- producerSeq: z.number().int().positive(),
104
- text: z.string().optional(),
391
+ var SessionAppendInputSchema = z3.object({
392
+ text: z3.string().optional(),
105
393
  payload: ArbitraryObjectSchema.optional(),
106
- type: z.string().optional(),
107
- source: z.string().optional(),
394
+ type: z3.string().optional(),
395
+ actor: z3.string().trim().min(1).optional(),
396
+ source: z3.string().optional(),
108
397
  metadata: ArbitraryObjectSchema.optional(),
109
398
  refs: ArbitraryObjectSchema.optional(),
110
- idempotencyKey: z.string().optional(),
111
- expectedSeq: z.number().int().nonnegative().optional()
399
+ idempotencyKey: z3.string().optional(),
400
+ expectedSeq: z3.number().int().nonnegative().optional()
112
401
  }).refine((value) => !!(value.text || value.payload), {
113
402
  message: "append() requires either 'text' or an object 'payload'"
114
403
  });
115
- var StarciteErrorPayloadSchema = z.object({
116
- error: z.string().optional(),
117
- message: z.string().optional()
118
- }).catchall(z.unknown());
404
+ var SessionListOptionsSchema = z3.object({
405
+ limit: z3.number().int().positive().optional(),
406
+ cursor: z3.string().trim().min(1).optional(),
407
+ metadata: z3.record(z3.string().trim().min(1), z3.string().trim().min(1)).optional()
408
+ });
119
409
 
120
- // src/client.ts
121
- var DEFAULT_BASE_URL = typeof process !== "undefined" && process.env.STARCITE_BASE_URL ? process.env.STARCITE_BASE_URL : "http://localhost:4000";
122
- var TRAILING_SLASHES_REGEX = /\/+$/;
123
- var BEARER_PREFIX_REGEX = /^bearer\s+/i;
124
- var DEFAULT_TAIL_RECONNECT_DELAY_MS = 3e3;
125
- var CATCH_UP_IDLE_MS = 1e3;
126
- var NORMAL_WEBSOCKET_CLOSE_CODE = 1e3;
127
- var SERVICE_TOKEN_SUB_ORG_PREFIX = "org:";
128
- var SERVICE_TOKEN_SUB_AGENT_PREFIX = "agent:";
129
- var SERVICE_TOKEN_SUB_USER_PREFIX = "user:";
130
- var TailFrameSchema = z2.string().transform((frame, context) => {
410
+ // src/tail/frame.ts
411
+ var MIN_TAIL_BATCH_SIZE = 1;
412
+ var TailFramePayloadSchema = z4.union([
413
+ TailEventSchema,
414
+ z4.array(TailEventSchema).min(MIN_TAIL_BATCH_SIZE)
415
+ ]);
416
+ function toFrameText(data) {
417
+ if (typeof data === "string") {
418
+ return data;
419
+ }
420
+ if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
421
+ return new TextDecoder().decode(data);
422
+ }
423
+ return void 0;
424
+ }
425
+ function parseTailFrame(data) {
426
+ const frameText = toFrameText(data);
427
+ if (!frameText) {
428
+ throw new StarciteConnectionError(
429
+ "Tail frame payload must be a UTF-8 string or binary buffer"
430
+ );
431
+ }
432
+ let framePayload;
131
433
  try {
132
- return JSON.parse(frame);
434
+ framePayload = JSON.parse(frameText);
133
435
  } catch {
134
- context.addIssue({
135
- code: z2.ZodIssueCode.custom,
136
- message: "Tail frame was not valid JSON"
436
+ throw new StarciteConnectionError("Tail frame was not valid JSON");
437
+ }
438
+ const result = TailFramePayloadSchema.safeParse(framePayload);
439
+ if (!result.success) {
440
+ const reason = result.error.issues[0]?.message ?? "Tail frame did not match schema";
441
+ throw new StarciteConnectionError(reason);
442
+ }
443
+ return Array.isArray(result.data) ? result.data : [result.data];
444
+ }
445
+
446
+ // src/tail/managed-websocket.ts
447
+ import EventEmitter2 from "eventemitter3";
448
+ var NORMAL_CLOSE_CODE = 1e3;
449
+ var INACTIVITY_CLOSE_CODE = 4e3;
450
+ var CONNECTION_TIMEOUT_CLOSE_CODE = 4100;
451
+ var ManagedWebSocket = class extends EventEmitter2 {
452
+ options;
453
+ socket;
454
+ cancelReconnectWait;
455
+ // Set while a socket run is active so `close()` can synchronously settle it.
456
+ forceCloseSocket;
457
+ started = false;
458
+ closed = false;
459
+ // Tracks reconnect attempts for backoff and retry-limit accounting.
460
+ reconnectAttempts = 0;
461
+ // Shared deferred completion for all callers of waitForClose(), even late ones.
462
+ donePromise;
463
+ // Set to `undefined` after resolution so finish() remains idempotent.
464
+ resolveDone;
465
+ constructor(options) {
466
+ super();
467
+ this.options = options;
468
+ this.donePromise = new Promise((resolve) => {
469
+ this.resolveDone = resolve;
137
470
  });
138
- return z2.NEVER;
139
- }
140
- }).pipe(TailEventSchema);
141
- var AsyncQueue = class {
142
- items = [];
143
- waiters = [];
144
- settled = false;
145
- push(value) {
146
- if (this.settled) {
471
+ }
472
+ close(code = NORMAL_CLOSE_CODE, reason = "closed") {
473
+ if (this.closed) {
147
474
  return;
148
475
  }
149
- this.enqueue({ type: "value", value });
150
- }
151
- close() {
152
- if (this.settled) {
476
+ this.closed = true;
477
+ this.cancelReconnectWait?.();
478
+ this.cancelReconnectWait = void 0;
479
+ if (!this.started) {
480
+ this.finish({
481
+ closeCode: code,
482
+ closeReason: reason,
483
+ aborted: this.options.signal?.aborted ?? false,
484
+ graceful: code === NORMAL_CLOSE_CODE
485
+ });
153
486
  return;
154
487
  }
155
- this.settled = true;
156
- this.enqueue({ type: "done" });
157
- }
158
- fail(error) {
159
- if (this.settled) {
488
+ if (this.forceCloseSocket) {
489
+ this.forceCloseSocket(code, reason);
160
490
  return;
161
491
  }
162
- this.settled = true;
163
- this.enqueue({ type: "error", error: toError(error) });
492
+ this.socket?.close(code, reason);
164
493
  }
165
- async next() {
166
- const item = this.items.shift() ?? await new Promise((resolve) => {
167
- this.waiters.push(resolve);
168
- });
169
- if (item.type === "value") {
170
- return { value: item.value, done: false };
171
- }
172
- if (item.type === "done") {
173
- return { value: void 0, done: true };
174
- }
175
- throw item.error;
494
+ resetReconnectAttempts() {
495
+ this.reconnectAttempts = 0;
176
496
  }
177
- enqueue(item) {
178
- const waiter = this.waiters.shift();
179
- if (waiter) {
180
- waiter(item);
497
+ waitForClose() {
498
+ this.start();
499
+ return this.donePromise;
500
+ }
501
+ start() {
502
+ if (this.started || this.resolveDone === void 0) {
181
503
  return;
182
504
  }
183
- this.items.push(item);
184
- }
185
- };
186
- function normalizeBaseUrl(baseUrl) {
187
- const trimmed = baseUrl.trim().replace(TRAILING_SLASHES_REGEX, "");
188
- return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
189
- }
190
- function toWebSocketBaseUrl(apiBaseUrl) {
191
- if (apiBaseUrl.startsWith("https://")) {
192
- return `wss://${apiBaseUrl.slice("https://".length)}`;
193
- }
194
- if (apiBaseUrl.startsWith("http://")) {
195
- return `ws://${apiBaseUrl.slice("http://".length)}`;
505
+ this.started = true;
506
+ this.run().catch((error) => {
507
+ this.emitSafe("fatal", error);
508
+ this.finish({
509
+ closeCode: void 0,
510
+ closeReason: "run failed",
511
+ aborted: this.options.signal?.aborted ?? false,
512
+ graceful: false
513
+ });
514
+ });
196
515
  }
197
- throw new StarciteError(
198
- `Invalid Starcite base URL '${apiBaseUrl}'. Use http:// or https://.`
199
- );
200
- }
201
- function defaultWebSocketFactory(url, options = {}) {
202
- if (typeof WebSocket === "undefined") {
203
- throw new StarciteError(
204
- "WebSocket is not available in this runtime. Provide websocketFactory in StarciteClientOptions."
205
- );
516
+ async run() {
517
+ const finalState = {
518
+ closeCode: void 0,
519
+ closeReason: void 0,
520
+ aborted: this.options.signal?.aborted ?? false,
521
+ graceful: false
522
+ };
523
+ while (!this.closed) {
524
+ if (this.options.signal?.aborted) {
525
+ this.closed = true;
526
+ finalState.aborted = true;
527
+ break;
528
+ }
529
+ const attempt = this.reconnectAttempts + 1;
530
+ if (!this.emitSafe("connect_attempt", { attempt })) {
531
+ this.closed = true;
532
+ break;
533
+ }
534
+ if (this.closed) {
535
+ break;
536
+ }
537
+ let socket;
538
+ try {
539
+ const url = this.options.url();
540
+ socket = this.options.websocketFactory(url);
541
+ } catch (error) {
542
+ const shouldContinue2 = await this.handleConnectFailure(attempt, error);
543
+ if (shouldContinue2) {
544
+ continue;
545
+ }
546
+ break;
547
+ }
548
+ if (this.closed) {
549
+ closeQuietly(socket, NORMAL_CLOSE_CODE, "closed");
550
+ break;
551
+ }
552
+ const result = await this.runSocket(socket);
553
+ const shouldContinue = await this.handleSocketResult(
554
+ attempt,
555
+ result,
556
+ finalState
557
+ );
558
+ if (shouldContinue) {
559
+ continue;
560
+ }
561
+ break;
562
+ }
563
+ this.finish(finalState);
206
564
  }
207
- const headers = new Headers(options.headers);
208
- if (!hasAnyHeaders(headers)) {
209
- return new WebSocket(url);
565
+ async handleConnectFailure(attempt, error) {
566
+ const rootCause = toErrorMessage(error);
567
+ if (!this.emitSafe("connect_failed", { attempt, rootCause })) {
568
+ this.closed = true;
569
+ return false;
570
+ }
571
+ return await this.scheduleReconnect({
572
+ attempt,
573
+ trigger: "connect_failed",
574
+ rootCause
575
+ });
210
576
  }
211
- const headerObject = Object.fromEntries(headers.entries());
212
- try {
213
- return new WebSocket(url, { headers: headerObject });
214
- } catch {
215
- throw new StarciteError(
216
- "This runtime cannot set WebSocket upgrade headers with the default factory. Provide websocketFactory in StarciteClientOptions."
217
- );
577
+ async handleSocketResult(attempt, result, finalState) {
578
+ finalState.closeCode = result.closeCode;
579
+ finalState.closeReason = result.closeReason;
580
+ finalState.aborted = result.aborted || (this.options.signal?.aborted ?? false);
581
+ finalState.graceful = !result.sawTransportError && result.closeCode === NORMAL_CLOSE_CODE;
582
+ if (result.listenerError !== void 0) {
583
+ this.emitSafe("fatal", result.listenerError);
584
+ this.closed = true;
585
+ return false;
586
+ }
587
+ if (this.closed || finalState.aborted || finalState.graceful) {
588
+ return false;
589
+ }
590
+ if (!this.emitSafe("dropped", {
591
+ attempt,
592
+ closeCode: result.closeCode,
593
+ closeReason: result.closeReason
594
+ })) {
595
+ this.closed = true;
596
+ return false;
597
+ }
598
+ return await this.scheduleReconnect({
599
+ attempt,
600
+ trigger: "dropped",
601
+ closeCode: result.closeCode,
602
+ closeReason: result.closeReason
603
+ });
218
604
  }
219
- }
220
- function defaultFetch(input, init) {
221
- if (typeof fetch === "undefined") {
222
- throw new StarciteError(
223
- "fetch is not available in this runtime. Provide fetch in StarciteClientOptions."
605
+ async scheduleReconnect(input) {
606
+ if (!this.options.shouldReconnect || this.closed || this.options.signal?.aborted) {
607
+ this.closed = true;
608
+ return false;
609
+ }
610
+ if (input.attempt > this.options.reconnectPolicy.maxAttempts) {
611
+ this.emitSafe("retry_limit", {
612
+ attempt: input.attempt,
613
+ trigger: input.trigger,
614
+ closeCode: input.closeCode,
615
+ closeReason: input.closeReason,
616
+ rootCause: input.rootCause
617
+ });
618
+ this.closed = true;
619
+ return false;
620
+ }
621
+ const delayMs = reconnectDelayForAttempt(
622
+ input.attempt,
623
+ this.options.reconnectPolicy
224
624
  );
625
+ if (!this.emitSafe("reconnect_scheduled", {
626
+ attempt: input.attempt,
627
+ delayMs,
628
+ trigger: input.trigger,
629
+ closeCode: input.closeCode,
630
+ closeReason: input.closeReason,
631
+ rootCause: input.rootCause
632
+ })) {
633
+ this.closed = true;
634
+ return false;
635
+ }
636
+ this.reconnectAttempts = input.attempt;
637
+ const reconnectWait = waitForDelay(delayMs, this.options.signal);
638
+ this.cancelReconnectWait = reconnectWait.cancel;
639
+ await reconnectWait.promise;
640
+ if (this.cancelReconnectWait === reconnectWait.cancel) {
641
+ this.cancelReconnectWait = void 0;
642
+ }
643
+ return !(this.closed || (this.options.signal?.aborted ?? false));
225
644
  }
226
- return fetch(input, init);
227
- }
228
- function toError(error) {
229
- if (error instanceof Error) {
230
- return error;
645
+ runSocket(socket) {
646
+ return new Promise((resolve) => {
647
+ this.socket = socket;
648
+ let settled = false;
649
+ let socketOpen = false;
650
+ let sawTransportError = false;
651
+ let aborted = false;
652
+ let listenerError;
653
+ let closeCode;
654
+ let closeReason;
655
+ let connectionTimeout;
656
+ let inactivityTimeout;
657
+ const clearTimers = () => {
658
+ if (connectionTimeout) {
659
+ clearTimeout(connectionTimeout);
660
+ connectionTimeout = void 0;
661
+ }
662
+ if (inactivityTimeout) {
663
+ clearTimeout(inactivityTimeout);
664
+ inactivityTimeout = void 0;
665
+ }
666
+ };
667
+ const armConnectionTimeout = () => {
668
+ clearTimeout(connectionTimeout);
669
+ if (this.options.connectionTimeoutMs <= 0) {
670
+ return;
671
+ }
672
+ connectionTimeout = setTimeout(() => {
673
+ if (settled || socketOpen || this.closed) {
674
+ return;
675
+ }
676
+ closeAndSettle(CONNECTION_TIMEOUT_CLOSE_CODE, "connection timeout");
677
+ }, this.options.connectionTimeoutMs);
678
+ };
679
+ const armInactivityTimeout = () => {
680
+ clearTimeout(inactivityTimeout);
681
+ const timeoutMs = this.options.inactivityTimeoutMs;
682
+ if (!timeoutMs || timeoutMs <= 0 || this.closed) {
683
+ return;
684
+ }
685
+ inactivityTimeout = setTimeout(() => {
686
+ if (settled || this.closed) {
687
+ return;
688
+ }
689
+ closeAndSettle(INACTIVITY_CLOSE_CODE, "inactivity timeout");
690
+ }, timeoutMs);
691
+ };
692
+ const closeAndSettle = (code, reason, markAborted = false) => {
693
+ if (settled) {
694
+ return;
695
+ }
696
+ aborted = aborted || markAborted;
697
+ closeCode = code;
698
+ closeReason = reason;
699
+ try {
700
+ socket.close(code, reason);
701
+ } catch {
702
+ }
703
+ settle();
704
+ };
705
+ const settle = () => {
706
+ if (settled) {
707
+ return;
708
+ }
709
+ settled = true;
710
+ clearTimers();
711
+ socket.removeEventListener("open", onOpen);
712
+ socket.removeEventListener("message", onMessage);
713
+ socket.removeEventListener("error", onError);
714
+ socket.removeEventListener("close", onClose);
715
+ this.options.signal?.removeEventListener("abort", onAbort);
716
+ if (this.socket === socket) {
717
+ this.socket = void 0;
718
+ }
719
+ this.forceCloseSocket = void 0;
720
+ resolve({
721
+ closeCode,
722
+ closeReason,
723
+ sawTransportError,
724
+ aborted,
725
+ listenerError
726
+ });
727
+ };
728
+ const onOpen = () => {
729
+ socketOpen = true;
730
+ clearTimeout(connectionTimeout);
731
+ armInactivityTimeout();
732
+ if (!this.emitSafe("open")) {
733
+ listenerError = new Error("Managed websocket open listener failed");
734
+ closeAndSettle(NORMAL_CLOSE_CODE, "listener failed");
735
+ }
736
+ };
737
+ const onMessage = (event) => {
738
+ armInactivityTimeout();
739
+ if (!this.emitSafe("message", event.data)) {
740
+ listenerError = new Error(
741
+ "Managed websocket message listener failed"
742
+ );
743
+ closeAndSettle(NORMAL_CLOSE_CODE, "listener failed");
744
+ }
745
+ };
746
+ const onError = (_event) => {
747
+ sawTransportError = true;
748
+ };
749
+ const onClose = (event) => {
750
+ closeCode = event.code;
751
+ closeReason = event.reason;
752
+ settle();
753
+ };
754
+ const onAbort = () => {
755
+ this.closed = true;
756
+ closeAndSettle(NORMAL_CLOSE_CODE, "aborted", true);
757
+ };
758
+ this.forceCloseSocket = (code, reason, markAborted) => {
759
+ closeAndSettle(code, reason, markAborted ?? false);
760
+ };
761
+ socket.addEventListener("open", onOpen);
762
+ socket.addEventListener("message", onMessage);
763
+ socket.addEventListener("error", onError);
764
+ socket.addEventListener("close", onClose);
765
+ this.options.signal?.addEventListener("abort", onAbort, { once: true });
766
+ armConnectionTimeout();
767
+ });
231
768
  }
232
- return new Error(typeof error === "string" ? error : "Unknown error");
233
- }
234
- function hasAnyHeaders(headers) {
235
- for (const _ of headers.keys()) {
236
- return true;
769
+ emitSafe(eventName, payload) {
770
+ const emitter = this;
771
+ try {
772
+ if (payload === void 0) {
773
+ emitter.emit(eventName);
774
+ } else {
775
+ emitter.emit(eventName, payload);
776
+ }
777
+ return true;
778
+ } catch (error) {
779
+ if (eventName !== "fatal") {
780
+ emitter.emit("fatal", error);
781
+ }
782
+ return false;
783
+ }
237
784
  }
238
- return false;
239
- }
240
- function formatAuthorizationHeader(apiKey) {
241
- const normalized = apiKey.trim();
242
- if (normalized.length === 0) {
243
- throw new StarciteError("apiKey cannot be empty");
785
+ finish(event) {
786
+ if (this.resolveDone === void 0) {
787
+ return;
788
+ }
789
+ this.emit("closed", event);
790
+ this.removeAllListeners();
791
+ const resolve = this.resolveDone;
792
+ this.resolveDone = void 0;
793
+ resolve();
244
794
  }
245
- if (BEARER_PREFIX_REGEX.test(normalized)) {
246
- return normalized;
795
+ };
796
+ function reconnectDelayForAttempt(attempt, policy) {
797
+ const exponent = Math.max(0, attempt - 1);
798
+ const baseDelay = policy.initialDelayMs * policy.multiplier ** exponent;
799
+ const clamped = Math.min(policy.maxDelayMs, baseDelay);
800
+ if (policy.jitterRatio <= 0) {
801
+ return clamped;
247
802
  }
248
- return `Bearer ${normalized}`;
249
- }
250
- function firstNonEmptyString(value) {
251
- return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
803
+ const spread = clamped * policy.jitterRatio;
804
+ const min = Math.max(0, clamped - spread);
805
+ const max = clamped + spread;
806
+ return min + Math.random() * (max - min);
252
807
  }
253
- function parseJwtSegment(segment) {
254
- const base64 = segment.replace(/-/g, "+").replace(/_/g, "/").padEnd(segment.length + (4 - segment.length % 4) % 4, "=");
255
- try {
256
- if (typeof atob === "function") {
257
- return atob(base64);
808
+ function waitForDelay(ms, signal) {
809
+ if (ms <= 0 || signal?.aborted) {
810
+ return {
811
+ promise: Promise.resolve(),
812
+ cancel: () => void 0
813
+ };
814
+ }
815
+ let timer;
816
+ let settled = false;
817
+ let resolvePromise;
818
+ const onAbort = () => {
819
+ finish();
820
+ };
821
+ const finish = () => {
822
+ if (settled) {
823
+ return;
258
824
  }
259
- if (typeof Buffer !== "undefined") {
260
- return Buffer.from(base64, "base64").toString("utf8");
825
+ settled = true;
826
+ if (timer) {
827
+ clearTimeout(timer);
828
+ timer = void 0;
261
829
  }
262
- } catch {
263
- return void 0;
264
- }
265
- return void 0;
830
+ signal?.removeEventListener("abort", onAbort);
831
+ resolvePromise?.();
832
+ resolvePromise = void 0;
833
+ };
834
+ const promise = new Promise((resolve) => {
835
+ resolvePromise = resolve;
836
+ timer = setTimeout(() => {
837
+ finish();
838
+ }, ms);
839
+ signal?.addEventListener("abort", onAbort, { once: true });
840
+ });
841
+ return {
842
+ promise,
843
+ cancel: finish
844
+ };
266
845
  }
267
- function parseJwtClaims(apiKey) {
268
- const token = apiKey.replace(BEARER_PREFIX_REGEX, "").trim();
269
- const parts = token.split(".");
270
- if (parts.length !== 3) {
271
- return void 0;
272
- }
273
- const [, payloadSegment] = parts;
274
- if (payloadSegment === void 0) {
275
- return void 0;
276
- }
277
- const payload = parseJwtSegment(payloadSegment);
278
- if (payload === void 0) {
279
- return void 0;
280
- }
846
+ function closeQuietly(socket, code, reason) {
281
847
  try {
282
- const decoded = JSON.parse(payload);
283
- return decoded !== null && typeof decoded === "object" ? decoded : void 0;
848
+ socket.close(code, reason);
284
849
  } catch {
285
- return void 0;
286
850
  }
287
851
  }
288
- function parseClaimStrings(source, keys) {
289
- for (const key of keys) {
290
- const value = firstNonEmptyString(source[key]);
291
- if (value) {
292
- return value;
293
- }
294
- }
295
- return void 0;
852
+ function toErrorMessage(error) {
853
+ return error instanceof Error ? error.message : String(error);
296
854
  }
297
- function parseActorIdentityFromSubject(subject) {
298
- if (subject.startsWith(SERVICE_TOKEN_SUB_AGENT_PREFIX)) {
299
- return { id: subject, type: "agent" };
855
+
856
+ // src/tail/stream.ts
857
+ var NORMAL_CLOSE_CODE2 = 1e3;
858
+ var TailStream = class {
859
+ sessionId;
860
+ token;
861
+ websocketBaseUrl;
862
+ websocketFactory;
863
+ batchSize;
864
+ agent;
865
+ follow;
866
+ shouldReconnect;
867
+ reconnectPolicy;
868
+ catchUpIdleMs;
869
+ connectionTimeoutMs;
870
+ inactivityTimeoutMs;
871
+ maxBufferedBatches;
872
+ signal;
873
+ onLifecycleEvent;
874
+ cursor;
875
+ constructor(input) {
876
+ const opts = input.options;
877
+ const follow = opts.follow ?? true;
878
+ const policy = opts.reconnectPolicy;
879
+ const mode = policy?.mode === "fixed" ? "fixed" : "exponential";
880
+ const initialDelayMs = policy?.initialDelayMs ?? 500;
881
+ this.sessionId = input.sessionId;
882
+ this.token = input.token;
883
+ this.websocketBaseUrl = input.websocketBaseUrl;
884
+ this.websocketFactory = input.websocketFactory;
885
+ this.cursor = opts.cursor ?? 0;
886
+ this.batchSize = opts.batchSize;
887
+ this.agent = opts.agent?.trim();
888
+ this.follow = follow;
889
+ this.shouldReconnect = follow ? opts.reconnect ?? true : false;
890
+ this.catchUpIdleMs = opts.catchUpIdleMs ?? 1e3;
891
+ this.connectionTimeoutMs = opts.connectionTimeoutMs ?? 4e3;
892
+ this.inactivityTimeoutMs = opts.inactivityTimeoutMs;
893
+ this.maxBufferedBatches = opts.maxBufferedBatches ?? 1024;
894
+ this.signal = opts.signal;
895
+ this.onLifecycleEvent = opts.onLifecycleEvent;
896
+ this.reconnectPolicy = {
897
+ mode,
898
+ initialDelayMs,
899
+ maxDelayMs: policy?.maxDelayMs ?? (mode === "fixed" ? initialDelayMs : Math.max(initialDelayMs, 15e3)),
900
+ multiplier: mode === "fixed" ? 1 : policy?.multiplier ?? 2,
901
+ jitterRatio: policy?.jitterRatio ?? 0.2,
902
+ maxAttempts: policy?.maxAttempts ?? Number.POSITIVE_INFINITY
903
+ };
300
904
  }
301
- if (subject.startsWith(SERVICE_TOKEN_SUB_USER_PREFIX)) {
302
- return { id: subject, type: "user" };
905
+ /**
906
+ * Pushes batches to a callback, enabling emitter-style consumers.
907
+ */
908
+ async subscribe(onBatch) {
909
+ await this.subscribeWithSignal(onBatch, this.signal);
303
910
  }
304
- return void 0;
305
- }
306
- function parseTenantIdFromSubject(subject) {
307
- const actorIdentity = parseActorIdentityFromSubject(subject);
308
- if (actorIdentity !== void 0) {
309
- return "";
911
+ async subscribeWithSignal(onBatch, signal) {
912
+ let streamError;
913
+ let streamReason = this.follow ? "graceful" : "caught_up";
914
+ let queuedBatches = 0;
915
+ let catchUpTimer;
916
+ let dispatchChain = Promise.resolve();
917
+ const stream = new ManagedWebSocket({
918
+ // URL is re-read per attempt by `ManagedWebSocket` and reflects current cursor.
919
+ url: () => this.buildTailUrl(),
920
+ websocketFactory: this.websocketFactory,
921
+ signal,
922
+ shouldReconnect: this.shouldReconnect,
923
+ reconnectPolicy: {
924
+ initialDelayMs: this.reconnectPolicy.initialDelayMs,
925
+ maxDelayMs: this.reconnectPolicy.maxDelayMs,
926
+ multiplier: this.reconnectPolicy.multiplier,
927
+ jitterRatio: this.reconnectPolicy.jitterRatio,
928
+ maxAttempts: this.reconnectPolicy.maxAttempts
929
+ },
930
+ connectionTimeoutMs: this.connectionTimeoutMs,
931
+ inactivityTimeoutMs: this.inactivityTimeoutMs
932
+ });
933
+ const fail = (error) => {
934
+ if (streamError !== void 0) {
935
+ return;
936
+ }
937
+ streamError = error;
938
+ stream.close(NORMAL_CLOSE_CODE2, "stream failed");
939
+ };
940
+ const emitLifecycle = (event) => {
941
+ if (!this.onLifecycleEvent) {
942
+ return;
943
+ }
944
+ try {
945
+ this.onLifecycleEvent(event);
946
+ } catch (error) {
947
+ fail(error);
948
+ }
949
+ };
950
+ const clearCatchUpTimer = () => {
951
+ if (catchUpTimer) {
952
+ clearTimeout(catchUpTimer);
953
+ catchUpTimer = void 0;
954
+ }
955
+ };
956
+ const scheduleCatchUpClose = () => {
957
+ if (this.follow) {
958
+ return;
959
+ }
960
+ clearCatchUpTimer();
961
+ catchUpTimer = setTimeout(() => {
962
+ streamReason = "caught_up";
963
+ stream.close(NORMAL_CLOSE_CODE2, "caught up");
964
+ }, this.catchUpIdleMs);
965
+ };
966
+ const dispatchBatch = (batch) => {
967
+ if (streamError !== void 0) {
968
+ return;
969
+ }
970
+ if (this.maxBufferedBatches > 0 && queuedBatches > this.maxBufferedBatches) {
971
+ fail(
972
+ new StarciteBackpressureError(
973
+ `Tail consumer for session '${this.sessionId}' fell behind after buffering ${this.maxBufferedBatches} batch(es)`,
974
+ { sessionId: this.sessionId, attempts: 0 }
975
+ )
976
+ );
977
+ return;
978
+ }
979
+ queuedBatches += 1;
980
+ dispatchChain = dispatchChain.then(async () => {
981
+ try {
982
+ await onBatch(batch);
983
+ } finally {
984
+ queuedBatches -= 1;
985
+ }
986
+ }).catch((error) => {
987
+ fail(error);
988
+ });
989
+ };
990
+ const onConnectAttempt = (event) => {
991
+ emitLifecycle({
992
+ type: "connect_attempt",
993
+ sessionId: this.sessionId,
994
+ attempt: event.attempt,
995
+ cursor: this.cursor
996
+ });
997
+ };
998
+ const onConnectFailed = (event) => {
999
+ if (!this.shouldReconnect) {
1000
+ fail(
1001
+ new StarciteTailError(
1002
+ `Tail connection failed for session '${this.sessionId}': ${event.rootCause}`,
1003
+ {
1004
+ sessionId: this.sessionId,
1005
+ stage: "connect",
1006
+ attempts: event.attempt - 1
1007
+ }
1008
+ )
1009
+ );
1010
+ }
1011
+ };
1012
+ const onReconnectScheduled = (event) => {
1013
+ emitLifecycle({
1014
+ type: "reconnect_scheduled",
1015
+ sessionId: this.sessionId,
1016
+ attempt: event.attempt,
1017
+ delayMs: event.delayMs,
1018
+ trigger: event.trigger,
1019
+ closeCode: event.closeCode,
1020
+ closeReason: event.closeReason
1021
+ });
1022
+ };
1023
+ const onDropped = (event) => {
1024
+ if (event.closeCode === 4001 || event.closeReason === "token_expired") {
1025
+ fail(
1026
+ new StarciteTokenExpiredError(
1027
+ `Tail token expired for session '${this.sessionId}'. Re-issue a session token and reconnect from the last processed cursor.`,
1028
+ {
1029
+ sessionId: this.sessionId,
1030
+ attempts: event.attempt,
1031
+ closeCode: event.closeCode,
1032
+ closeReason: event.closeReason
1033
+ }
1034
+ )
1035
+ );
1036
+ return;
1037
+ }
1038
+ emitLifecycle({
1039
+ type: "stream_dropped",
1040
+ sessionId: this.sessionId,
1041
+ attempt: event.attempt,
1042
+ closeCode: event.closeCode,
1043
+ closeReason: event.closeReason
1044
+ });
1045
+ if (!this.shouldReconnect) {
1046
+ fail(
1047
+ new StarciteTailError(
1048
+ `Tail connection dropped for session '${this.sessionId}' (${describeClose(event.closeCode, event.closeReason)})`,
1049
+ {
1050
+ sessionId: this.sessionId,
1051
+ stage: "stream",
1052
+ attempts: event.attempt - 1,
1053
+ closeCode: event.closeCode,
1054
+ closeReason: event.closeReason
1055
+ }
1056
+ )
1057
+ );
1058
+ }
1059
+ };
1060
+ const onRetryLimit = (event) => {
1061
+ const message = event.trigger === "connect_failed" ? `Tail connection failed for session '${this.sessionId}' after ${this.reconnectPolicy.maxAttempts} reconnect attempt(s): ${event.rootCause ?? "Unknown error"}` : `Tail connection dropped for session '${this.sessionId}' after ${this.reconnectPolicy.maxAttempts} reconnect attempt(s) (${describeClose(event.closeCode, event.closeReason)})`;
1062
+ fail(
1063
+ new StarciteRetryLimitError(message, {
1064
+ sessionId: this.sessionId,
1065
+ attempts: event.attempt,
1066
+ closeCode: event.closeCode,
1067
+ closeReason: event.closeReason
1068
+ })
1069
+ );
1070
+ };
1071
+ const onOpen = () => {
1072
+ scheduleCatchUpClose();
1073
+ };
1074
+ const onMessage = (data) => {
1075
+ try {
1076
+ const parsedEvents = parseTailFrame(data);
1077
+ const matchingEvents = [];
1078
+ for (const parsedEvent of parsedEvents) {
1079
+ this.cursor = Math.max(this.cursor, parsedEvent.seq);
1080
+ if (this.agent && agentFromActor(parsedEvent.actor) !== this.agent) {
1081
+ continue;
1082
+ }
1083
+ matchingEvents.push(parsedEvent);
1084
+ }
1085
+ if (matchingEvents.length > 0) {
1086
+ stream.resetReconnectAttempts();
1087
+ dispatchBatch(matchingEvents);
1088
+ }
1089
+ scheduleCatchUpClose();
1090
+ } catch (error) {
1091
+ fail(error);
1092
+ }
1093
+ };
1094
+ const onFatal = (error) => {
1095
+ fail(error);
1096
+ };
1097
+ const onClosed = (event) => {
1098
+ clearCatchUpTimer();
1099
+ if (event.aborted) {
1100
+ streamReason = "aborted";
1101
+ return;
1102
+ }
1103
+ if (!this.follow) {
1104
+ streamReason = "caught_up";
1105
+ return;
1106
+ }
1107
+ if (event.graceful) {
1108
+ streamReason = "graceful";
1109
+ }
1110
+ };
1111
+ stream.on("connect_attempt", onConnectAttempt);
1112
+ stream.on("connect_failed", onConnectFailed);
1113
+ stream.on("reconnect_scheduled", onReconnectScheduled);
1114
+ stream.on("dropped", onDropped);
1115
+ stream.on("retry_limit", onRetryLimit);
1116
+ stream.on("open", onOpen);
1117
+ stream.on("message", onMessage);
1118
+ stream.on("fatal", onFatal);
1119
+ stream.on("closed", onClosed);
1120
+ try {
1121
+ await stream.waitForClose();
1122
+ clearCatchUpTimer();
1123
+ await dispatchChain;
1124
+ if (streamError !== void 0) {
1125
+ throw streamError;
1126
+ }
1127
+ emitLifecycle({
1128
+ type: "stream_ended",
1129
+ sessionId: this.sessionId,
1130
+ reason: streamReason
1131
+ });
1132
+ if (streamError !== void 0) {
1133
+ throw streamError;
1134
+ }
1135
+ } finally {
1136
+ clearCatchUpTimer();
1137
+ stream.off("connect_attempt", onConnectAttempt);
1138
+ stream.off("connect_failed", onConnectFailed);
1139
+ stream.off("reconnect_scheduled", onReconnectScheduled);
1140
+ stream.off("dropped", onDropped);
1141
+ stream.off("retry_limit", onRetryLimit);
1142
+ stream.off("open", onOpen);
1143
+ stream.off("message", onMessage);
1144
+ stream.off("fatal", onFatal);
1145
+ stream.off("closed", onClosed);
1146
+ stream.close(NORMAL_CLOSE_CODE2, "finished");
1147
+ }
310
1148
  }
311
- if (subject.startsWith(SERVICE_TOKEN_SUB_ORG_PREFIX)) {
312
- return subject.slice(SERVICE_TOKEN_SUB_ORG_PREFIX.length).trim();
1149
+ buildTailUrl() {
1150
+ const query = new URLSearchParams({ cursor: `${this.cursor}` });
1151
+ if (this.batchSize !== void 0) {
1152
+ query.set("batch_size", `${this.batchSize}`);
1153
+ }
1154
+ if (this.token) {
1155
+ query.set("access_token", this.token);
1156
+ }
1157
+ return `${this.websocketBaseUrl}/sessions/${encodeURIComponent(
1158
+ this.sessionId
1159
+ )}/tail?${query.toString()}`;
313
1160
  }
314
- return subject;
1161
+ };
1162
+ function describeClose(code, reason) {
1163
+ const codeText = `code ${code ?? "unknown"}`;
1164
+ return reason ? `${codeText}, reason '${reason}'` : codeText;
315
1165
  }
316
- function parseCreatorPrincipalFromClaims(claims) {
317
- const subject = firstNonEmptyString(claims.sub);
318
- const explicitPrincipal = claims.principal && typeof claims.principal === "object" ? claims.principal : void 0;
319
- const mergedClaims = explicitPrincipal ? { ...claims, ...explicitPrincipal } : claims;
320
- const actorFromSubject = subject ? parseActorIdentityFromSubject(subject) : void 0;
321
- const principalTypeFromClaims = parseClaimStrings(mergedClaims, [
322
- "principal_type",
323
- "principalType",
324
- "type"
325
- ]);
326
- const tenantId = parseClaimStrings(mergedClaims, ["tenant_id", "tenantId"]);
327
- const rawPrincipalId = parseClaimStrings(mergedClaims, [
328
- "principal_id",
329
- "principalId",
330
- "id",
331
- "sub"
332
- ]);
333
- const actorFromRawId = rawPrincipalId ? parseActorIdentityFromSubject(rawPrincipalId) : void 0;
334
- const principal = {
335
- tenant_id: tenantId ?? (subject ? parseTenantIdFromSubject(subject) : ""),
336
- id: rawPrincipalId ?? actorFromSubject?.id ?? "",
337
- type: principalTypeFromClaims === "agent" || principalTypeFromClaims === "user" ? principalTypeFromClaims : actorFromSubject?.type ?? actorFromRawId?.type ?? "user"
338
- };
339
- if (principal.tenant_id.length === 0 || principal.id.length === 0 || principal.type.length === 0) {
340
- return void 0;
1166
+
1167
+ // src/transport.ts
1168
+ var TRAILING_SLASHES_REGEX = /\/+$/;
1169
+ function normalizeAbsoluteHttpUrl(value, context) {
1170
+ const parsed = new URL(value);
1171
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1172
+ throw new StarciteError(`${context} must use http:// or https://`);
341
1173
  }
342
- const result = SessionCreatorPrincipalSchema.safeParse(principal);
343
- return result.success ? result.data : void 0;
1174
+ return parsed.toString().replace(TRAILING_SLASHES_REGEX, "");
344
1175
  }
345
- function parseCreatorPrincipalFromClaimsSafe(apiKey) {
346
- const claims = parseJwtClaims(apiKey);
347
- return claims ? parseCreatorPrincipalFromClaims(claims) : void 0;
1176
+ function toApiBaseUrl(baseUrl) {
1177
+ const normalized = normalizeAbsoluteHttpUrl(baseUrl, "baseUrl");
1178
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
348
1179
  }
349
- function parseEventFrame(data) {
350
- const result = TailFrameSchema.safeParse(data);
351
- if (!result.success) {
352
- const reason = result.error.issues[0]?.message ?? "Tail frame did not match schema";
353
- throw new StarciteConnectionError(reason);
354
- }
355
- return result.data;
1180
+ function toWebSocketBaseUrl(apiBaseUrl) {
1181
+ const url = new URL(apiBaseUrl);
1182
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1183
+ return url.toString().replace(TRAILING_SLASHES_REGEX, "");
356
1184
  }
357
- function getEventData(event) {
358
- if (event && typeof event === "object" && "data" in event) {
359
- return event.data;
1185
+ function defaultWebSocketFactory(url) {
1186
+ if (typeof WebSocket === "undefined") {
1187
+ throw new StarciteError(
1188
+ "WebSocket is not available in this runtime. Provide websocketFactory in StarciteOptions."
1189
+ );
360
1190
  }
361
- return void 0;
1191
+ return new WebSocket(url);
362
1192
  }
363
- function getCloseCode(event) {
364
- if (event && typeof event === "object" && "code" in event) {
365
- const code = event.code;
366
- return typeof code === "number" ? code : void 0;
367
- }
368
- return void 0;
1193
+ function request(transport, path, init, schema) {
1194
+ return requestWithBaseUrl(transport, transport.baseUrl, path, init, schema);
369
1195
  }
370
- function getCloseReason(event) {
371
- if (event && typeof event === "object" && "reason" in event) {
372
- const reason = event.reason;
373
- return typeof reason === "string" && reason.length > 0 ? reason : void 0;
1196
+ async function requestWithBaseUrl(transport, baseUrl, path, init, schema) {
1197
+ const headers = new Headers(transport.headers);
1198
+ if (transport.authorization) {
1199
+ headers.set("authorization", transport.authorization);
374
1200
  }
375
- return void 0;
376
- }
377
- function describeClose(code, reason) {
378
- const codeText = `code ${typeof code === "number" ? code : "unknown"}`;
379
- return reason ? `${codeText}, reason '${reason}'` : codeText;
380
- }
381
- async function waitForDelay(ms, signal) {
382
- if (ms <= 0) {
383
- return;
384
- }
385
- await new Promise((resolve) => {
386
- let settled = false;
387
- const timeout = setTimeout(() => {
388
- if (settled) {
389
- return;
390
- }
391
- settled = true;
392
- if (signal) {
393
- signal.removeEventListener("abort", onAbort);
394
- }
395
- resolve();
396
- }, ms);
397
- const onAbort = () => {
398
- if (settled) {
399
- return;
400
- }
401
- settled = true;
402
- clearTimeout(timeout);
403
- signal?.removeEventListener("abort", onAbort);
404
- resolve();
405
- };
406
- if (signal) {
407
- if (signal.aborted) {
408
- onAbort();
409
- return;
410
- }
411
- signal.addEventListener("abort", onAbort, { once: true });
1201
+ if (init.body !== void 0 && !headers.has("content-type")) {
1202
+ headers.set("content-type", "application/json");
1203
+ }
1204
+ if (init.headers) {
1205
+ const perRequestHeaders = new Headers(init.headers);
1206
+ for (const [key, value] of perRequestHeaders.entries()) {
1207
+ headers.set(key, value);
412
1208
  }
413
- });
414
- }
415
- function agentFromActor(actor) {
416
- if (actor.startsWith("agent:")) {
417
- return actor.slice("agent:".length);
418
1209
  }
419
- return void 0;
420
- }
421
- function toSessionEvent(event) {
422
- const agent = agentFromActor(event.actor);
423
- const text = typeof event.payload.text === "string" ? event.payload.text : void 0;
424
- return {
425
- ...event,
426
- agent,
427
- text
428
- };
1210
+ let response;
1211
+ try {
1212
+ response = await transport.fetchFn(`${baseUrl}${path}`, {
1213
+ ...init,
1214
+ headers
1215
+ });
1216
+ } catch (error) {
1217
+ throw new StarciteConnectionError(
1218
+ `Failed to connect to Starcite at ${baseUrl}: ${error instanceof Error ? error.message : String(error)}`
1219
+ );
1220
+ }
1221
+ if (!response.ok) {
1222
+ let payload = null;
1223
+ try {
1224
+ payload = await response.json();
1225
+ } catch {
1226
+ }
1227
+ const code = typeof payload?.error === "string" ? payload.error : `http_${response.status}`;
1228
+ const message = typeof payload?.message === "string" ? payload.message : response.statusText;
1229
+ throw new StarciteApiError(message, response.status, code, payload);
1230
+ }
1231
+ if (response.status === 204) {
1232
+ return schema.parse(void 0);
1233
+ }
1234
+ let body;
1235
+ try {
1236
+ body = await response.json();
1237
+ } catch (error) {
1238
+ throw new StarciteConnectionError(
1239
+ `Received invalid JSON from Starcite: ${error instanceof Error ? error.message : String(error)}`
1240
+ );
1241
+ }
1242
+ return schema.parse(body);
429
1243
  }
1244
+
1245
+ // src/session.ts
430
1246
  var StarciteSession = class {
431
1247
  /** Session identifier. */
432
1248
  id;
1249
+ /** The session JWT used for auth. Extract this for frontend handoff. */
1250
+ token;
1251
+ /** Identity bound to this session. */
1252
+ identity;
433
1253
  /** Optional session record captured at creation time. */
434
1254
  record;
435
- client;
436
- constructor(client, id, record) {
437
- this.client = client;
438
- this.id = id;
439
- this.record = record;
1255
+ transport;
1256
+ producerId;
1257
+ producerSeq = 0;
1258
+ log;
1259
+ store;
1260
+ lifecycle = new EventEmitter3();
1261
+ eventSubscriptions = /* @__PURE__ */ new Map();
1262
+ liveSyncController;
1263
+ liveSyncTask;
1264
+ constructor(options) {
1265
+ this.id = options.id;
1266
+ this.token = options.token;
1267
+ this.identity = options.identity;
1268
+ this.transport = options.transport;
1269
+ this.record = options.record;
1270
+ this.producerId = crypto.randomUUID();
1271
+ this.store = options.store;
1272
+ this.log = new SessionLog(options.logOptions);
1273
+ const storedState = this.store.load(this.id);
1274
+ if (storedState) {
1275
+ this.log.hydrate(storedState);
1276
+ }
440
1277
  }
441
1278
  /**
442
- * Appends a high-level agent event to this session.
1279
+ * Appends an event to this session.
443
1280
  *
444
- * Automatically prefixes `agent` as `agent:<name>` when needed.
1281
+ * The SDK manages `actor`, `producer_id`, and `producer_seq` automatically.
445
1282
  */
446
- append(input) {
1283
+ async append(input, options) {
447
1284
  const parsed = SessionAppendInputSchema.parse(input);
448
- const actor = parsed.agent.startsWith("agent:") ? parsed.agent : `agent:${parsed.agent}`;
449
- return this.client.appendEvent(this.id, {
450
- type: parsed.type ?? "content",
451
- payload: parsed.payload ?? { text: parsed.text },
452
- actor,
453
- producer_id: parsed.producerId,
454
- producer_seq: parsed.producerSeq,
455
- source: parsed.source ?? "agent",
456
- metadata: parsed.metadata,
457
- refs: parsed.refs,
458
- idempotency_key: parsed.idempotencyKey,
459
- expected_seq: parsed.expectedSeq
460
- });
1285
+ this.producerSeq += 1;
1286
+ const result = await this.appendRaw(
1287
+ {
1288
+ type: parsed.type ?? "content",
1289
+ payload: parsed.payload ?? { text: parsed.text },
1290
+ actor: parsed.actor ?? this.identity.toActor(),
1291
+ producer_id: this.producerId,
1292
+ producer_seq: this.producerSeq,
1293
+ source: parsed.source ?? "agent",
1294
+ metadata: parsed.metadata,
1295
+ refs: parsed.refs,
1296
+ idempotency_key: parsed.idempotencyKey,
1297
+ expected_seq: parsed.expectedSeq
1298
+ },
1299
+ options
1300
+ );
1301
+ return {
1302
+ seq: result.seq,
1303
+ deduped: result.deduped
1304
+ };
461
1305
  }
462
1306
  /**
463
- * Appends a raw event payload as-is.
1307
+ * Appends a raw event payload as-is. Caller manages all fields.
464
1308
  */
465
- appendRaw(input) {
466
- return this.client.appendEvent(this.id, input);
1309
+ appendRaw(input, options) {
1310
+ return request(
1311
+ this.transport,
1312
+ `/sessions/${encodeURIComponent(this.id)}/append`,
1313
+ {
1314
+ method: "POST",
1315
+ body: JSON.stringify(input),
1316
+ signal: options?.signal
1317
+ },
1318
+ AppendEventResponseSchema
1319
+ );
1320
+ }
1321
+ on(eventName, listener) {
1322
+ if (eventName === "event") {
1323
+ const eventListener = listener;
1324
+ if (!this.eventSubscriptions.has(eventListener)) {
1325
+ const unsubscribe = this.log.subscribe(eventListener, { replay: true });
1326
+ this.eventSubscriptions.set(eventListener, unsubscribe);
1327
+ }
1328
+ this.ensureLiveSync();
1329
+ return () => {
1330
+ this.off("event", eventListener);
1331
+ };
1332
+ }
1333
+ if (eventName === "error") {
1334
+ const errorListener = listener;
1335
+ this.lifecycle.on("error", errorListener);
1336
+ return () => {
1337
+ this.off("error", errorListener);
1338
+ };
1339
+ }
1340
+ throw new StarciteError(`Unsupported event name '${eventName}'`);
1341
+ }
1342
+ off(eventName, listener) {
1343
+ if (eventName === "event") {
1344
+ const eventListener = listener;
1345
+ const unsubscribe = this.eventSubscriptions.get(eventListener);
1346
+ if (!unsubscribe) {
1347
+ return;
1348
+ }
1349
+ this.eventSubscriptions.delete(eventListener);
1350
+ unsubscribe();
1351
+ if (this.eventSubscriptions.size === 0) {
1352
+ this.liveSyncController?.abort();
1353
+ }
1354
+ return;
1355
+ }
1356
+ if (eventName === "error") {
1357
+ this.lifecycle.off("error", listener);
1358
+ return;
1359
+ }
1360
+ throw new StarciteError(`Unsupported event name '${eventName}'`);
467
1361
  }
468
1362
  /**
469
- * Streams transformed session events with SDK convenience fields (`agent`, `text`).
1363
+ * Stops live syncing and removes listeners registered via `on()`.
470
1364
  */
471
- tail(options = {}) {
472
- return this.client.tailEvents(this.id, options);
1365
+ disconnect() {
1366
+ this.liveSyncController?.abort();
1367
+ for (const unsubscribe of this.eventSubscriptions.values()) {
1368
+ unsubscribe();
1369
+ }
1370
+ this.eventSubscriptions.clear();
1371
+ this.lifecycle.removeAllListeners();
473
1372
  }
474
1373
  /**
475
- * Streams raw tail events returned by the API.
1374
+ * Backwards-compatible alias for `disconnect()`.
476
1375
  */
477
- tailRaw(options = {}) {
478
- return this.client.tailRawEvents(this.id, options);
1376
+ close() {
1377
+ this.disconnect();
479
1378
  }
480
- };
481
- var StarciteClient = class {
482
- /** Normalized API base URL ending with `/v1`. */
483
- baseUrl;
484
- inferredCreatorPrincipal;
485
- websocketBaseUrl;
486
- fetchFn;
487
- headers;
488
- websocketFactory;
489
1379
  /**
490
- * Creates a new client instance.
1380
+ * Updates in-memory session log retention.
491
1381
  */
492
- constructor(options = {}) {
493
- this.baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
494
- this.websocketBaseUrl = toWebSocketBaseUrl(this.baseUrl);
495
- this.fetchFn = options.fetch ?? defaultFetch;
496
- this.headers = new Headers(options.headers);
497
- if (options.apiKey !== void 0) {
498
- const authorization = formatAuthorizationHeader(options.apiKey);
499
- this.headers.set("authorization", authorization);
500
- this.inferredCreatorPrincipal = parseCreatorPrincipalFromClaimsSafe(authorization);
501
- }
502
- this.websocketFactory = options.websocketFactory ?? defaultWebSocketFactory;
1382
+ setLogOptions(options) {
1383
+ this.log.setMaxEvents(options.maxEvents);
1384
+ this.persistLogState();
503
1385
  }
504
1386
  /**
505
- * Returns a session helper bound to an existing session id.
1387
+ * Returns a stable snapshot of the current canonical in-memory log.
506
1388
  */
507
- session(sessionId, record) {
508
- return new StarciteSession(this, sessionId, record);
1389
+ getSnapshot() {
1390
+ return this.log.getSnapshot(this.liveSyncTask !== void 0);
509
1391
  }
510
1392
  /**
511
- * Creates a new session and returns a bound `StarciteSession` helper.
1393
+ * Streams tail events one at a time via callback.
512
1394
  */
513
- async create(input = {}) {
514
- const record = await this.createSession(input);
515
- return this.session(record.id, record);
1395
+ async tail(onEvent, options = {}) {
1396
+ await this.tailBatches(async (batch) => {
1397
+ for (const event of batch) {
1398
+ await onEvent(event);
1399
+ }
1400
+ }, options);
516
1401
  }
517
1402
  /**
518
- * Creates a new session and returns the raw session record.
1403
+ * Streams tail event batches grouped by incoming frame via callback.
519
1404
  */
520
- createSession(input = {}) {
521
- const payload = CreateSessionInputSchema.parse({
522
- ...input,
523
- creator_principal: input.creator_principal ?? this.inferredCreatorPrincipal
524
- });
525
- return this.request(
526
- "/sessions",
527
- {
528
- method: "POST",
529
- body: JSON.stringify(payload)
530
- },
531
- SessionRecordSchema
532
- );
1405
+ async tailBatches(onBatch, options = {}) {
1406
+ await new TailStream({
1407
+ sessionId: this.id,
1408
+ token: this.token,
1409
+ websocketBaseUrl: this.transport.websocketBaseUrl,
1410
+ websocketFactory: this.transport.websocketFactory,
1411
+ options
1412
+ }).subscribe(onBatch);
533
1413
  }
534
1414
  /**
535
- * Lists sessions from the archive-backed catalog.
1415
+ * Durably consumes events and checkpoints `event.seq` after each successful handler invocation.
536
1416
  */
537
- listSessions(options = {}) {
538
- const query = new URLSearchParams();
539
- if (options.limit !== void 0) {
540
- if (!Number.isInteger(options.limit) || options.limit <= 0) {
1417
+ async consume(options) {
1418
+ const {
1419
+ cursorStore,
1420
+ handler,
1421
+ cursor: requestedCursor,
1422
+ ...tailOptions
1423
+ } = options;
1424
+ let cursor;
1425
+ if (requestedCursor !== void 0) {
1426
+ cursor = requestedCursor;
1427
+ } else {
1428
+ try {
1429
+ cursor = await cursorStore.load(this.id) ?? 0;
1430
+ } catch (error) {
541
1431
  throw new StarciteError(
542
- "listSessions() limit must be a positive integer"
1432
+ `consume() failed to load cursor for session '${this.id}': ${error instanceof Error ? error.message : String(error)}`
543
1433
  );
544
1434
  }
545
- query.set("limit", `${options.limit}`);
546
1435
  }
547
- if (options.cursor !== void 0) {
548
- if (options.cursor.trim().length === 0) {
549
- throw new StarciteError("listSessions() cursor cannot be empty");
1436
+ const stream = new TailStream({
1437
+ sessionId: this.id,
1438
+ token: this.token,
1439
+ websocketBaseUrl: this.transport.websocketBaseUrl,
1440
+ websocketFactory: this.transport.websocketFactory,
1441
+ options: {
1442
+ ...tailOptions,
1443
+ cursor
550
1444
  }
551
- query.set("cursor", options.cursor);
552
- }
553
- if (options.metadata !== void 0) {
554
- for (const [key, value] of Object.entries(options.metadata)) {
555
- if (key.trim().length === 0 || value.trim().length === 0) {
1445
+ });
1446
+ await stream.subscribe(async (batch) => {
1447
+ for (const event of batch) {
1448
+ await handler(event);
1449
+ try {
1450
+ await cursorStore.save(this.id, event.seq);
1451
+ } catch (error) {
556
1452
  throw new StarciteError(
557
- "listSessions() metadata filters must be non-empty strings"
1453
+ `consume() failed to save cursor for session '${this.id}': ${error instanceof Error ? error.message : String(error)}`
558
1454
  );
559
1455
  }
560
- query.set(`metadata.${key}`, value);
561
1456
  }
562
- }
563
- const suffix = query.size > 0 ? `?${query.toString()}` : "";
564
- return this.request(
565
- `/sessions${suffix}`,
566
- {
567
- method: "GET"
568
- },
569
- SessionListPageSchema
570
- );
1457
+ });
571
1458
  }
572
- /**
573
- * Appends a raw event payload to a specific session.
574
- */
575
- appendEvent(sessionId, input) {
576
- const payload = AppendEventRequestSchema.parse(input);
577
- return this.request(
578
- `/sessions/${encodeURIComponent(sessionId)}/append`,
579
- {
580
- method: "POST",
581
- body: JSON.stringify(payload)
582
- },
583
- AppendEventResponseSchema
584
- );
1459
+ emitStreamError(error) {
1460
+ const streamError = error instanceof Error ? error : new StarciteError(`Session stream failed: ${String(error)}`);
1461
+ if (this.lifecycle.listenerCount("error") > 0) {
1462
+ this.lifecycle.emit("error", streamError);
1463
+ return;
1464
+ }
1465
+ queueMicrotask(() => {
1466
+ throw streamError;
1467
+ });
585
1468
  }
586
- /**
587
- * Opens a WebSocket tail stream and yields raw events.
588
- */
589
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: single-loop reconnect state machine is intentionally explicit for stream correctness.
590
- async *tailRawEvents(sessionId, options = {}) {
591
- const initialCursor = options.cursor ?? 0;
592
- const follow = options.follow ?? true;
593
- const reconnectEnabled = follow ? options.reconnect ?? true : false;
594
- const reconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_TAIL_RECONNECT_DELAY_MS;
595
- if (!Number.isInteger(initialCursor) || initialCursor < 0) {
596
- throw new StarciteError("tail() cursor must be a non-negative integer");
597
- }
598
- if (!Number.isFinite(reconnectDelayMs) || reconnectDelayMs < 0) {
599
- throw new StarciteError(
600
- "tail() reconnectDelayMs must be a non-negative number"
601
- );
1469
+ ensureLiveSync() {
1470
+ if (this.liveSyncTask || this.eventSubscriptions.size === 0) {
1471
+ return;
602
1472
  }
603
- let cursor = initialCursor;
604
- while (true) {
605
- if (options.signal?.aborted) {
606
- return;
607
- }
608
- const wsUrl = `${this.websocketBaseUrl}/sessions/${encodeURIComponent(
609
- sessionId
610
- )}/tail?cursor=${cursor}`;
611
- const websocketHeaders = new Headers();
612
- const authorization = this.headers.get("authorization");
613
- if (authorization) {
614
- websocketHeaders.set("authorization", authorization);
1473
+ const controller = new AbortController();
1474
+ this.liveSyncController = controller;
1475
+ this.liveSyncTask = this.runLiveSync(controller.signal).catch((error) => {
1476
+ if (!controller.signal.aborted) {
1477
+ this.emitStreamError(error);
615
1478
  }
616
- let socket;
617
- try {
618
- socket = this.websocketFactory(
619
- wsUrl,
620
- hasAnyHeaders(websocketHeaders) ? {
621
- headers: websocketHeaders
622
- } : void 0
623
- );
624
- } catch (error) {
625
- const rootCause = toError(error).message;
626
- if (!reconnectEnabled || options.signal?.aborted) {
627
- throw new StarciteConnectionError(
628
- `Tail connection failed for session '${sessionId}': ${rootCause}`
629
- );
630
- }
631
- await waitForDelay(reconnectDelayMs, options.signal);
632
- continue;
633
- }
634
- const queue = new AsyncQueue();
635
- let sawTransportError = false;
636
- let closeCode;
637
- let closeReason;
638
- let abortRequested = false;
639
- let catchUpTimer = null;
640
- const resetCatchUpTimer = () => {
641
- if (!follow) {
642
- if (catchUpTimer) {
643
- clearTimeout(catchUpTimer);
644
- }
645
- catchUpTimer = setTimeout(() => {
646
- queue.close();
647
- }, CATCH_UP_IDLE_MS);
648
- }
649
- };
650
- const onMessage = (event) => {
651
- try {
652
- const parsed = parseEventFrame(getEventData(event));
653
- cursor = Math.max(cursor, parsed.seq);
654
- if (options.agent && agentFromActor(parsed.actor) !== options.agent) {
655
- resetCatchUpTimer();
656
- return;
657
- }
658
- queue.push(parsed);
659
- resetCatchUpTimer();
660
- } catch (error) {
661
- queue.fail(error);
662
- }
663
- };
664
- const onError = () => {
665
- sawTransportError = true;
666
- if (catchUpTimer) {
667
- clearTimeout(catchUpTimer);
668
- }
669
- queue.close();
670
- };
671
- const onClose = (event) => {
672
- closeCode = getCloseCode(event);
673
- closeReason = getCloseReason(event);
674
- if (catchUpTimer) {
675
- clearTimeout(catchUpTimer);
676
- }
677
- queue.close();
678
- };
679
- const onAbort = () => {
680
- abortRequested = true;
681
- if (catchUpTimer) {
682
- clearTimeout(catchUpTimer);
683
- }
684
- queue.close();
685
- socket.close(NORMAL_WEBSOCKET_CLOSE_CODE, "aborted");
686
- };
687
- const onOpen = () => {
688
- resetCatchUpTimer();
689
- };
690
- socket.addEventListener("open", onOpen);
691
- socket.addEventListener("message", onMessage);
692
- socket.addEventListener("error", onError);
693
- socket.addEventListener("close", onClose);
694
- if (options.signal) {
695
- if (options.signal.aborted) {
696
- onAbort();
697
- } else {
698
- options.signal.addEventListener("abort", onAbort, { once: true });
1479
+ }).finally(() => {
1480
+ this.liveSyncTask = void 0;
1481
+ this.liveSyncController = void 0;
1482
+ });
1483
+ }
1484
+ async runLiveSync(signal) {
1485
+ while (!signal.aborted && this.eventSubscriptions.size > 0) {
1486
+ const stream = new TailStream({
1487
+ sessionId: this.id,
1488
+ token: this.token,
1489
+ websocketBaseUrl: this.transport.websocketBaseUrl,
1490
+ websocketFactory: this.transport.websocketFactory,
1491
+ options: {
1492
+ cursor: this.log.lastSeq,
1493
+ signal
699
1494
  }
700
- }
701
- let iterationError = null;
1495
+ });
702
1496
  try {
703
- while (true) {
704
- const next = await queue.next();
705
- if (next.done) {
706
- break;
1497
+ await stream.subscribe((batch) => {
1498
+ const appliedEvents = this.log.applyBatch(batch);
1499
+ if (appliedEvents.length > 0) {
1500
+ this.persistLogState();
707
1501
  }
708
- yield next.value;
709
- }
1502
+ });
710
1503
  } catch (error) {
711
- iterationError = toError(error);
712
- } finally {
713
- if (catchUpTimer) {
714
- clearTimeout(catchUpTimer);
1504
+ if (signal.aborted) {
1505
+ return;
715
1506
  }
716
- socket.removeEventListener("open", onOpen);
717
- socket.removeEventListener("message", onMessage);
718
- socket.removeEventListener("error", onError);
719
- socket.removeEventListener("close", onClose);
720
- if (options.signal) {
721
- options.signal.removeEventListener("abort", onAbort);
1507
+ if (error instanceof SessionLogGapError) {
1508
+ continue;
722
1509
  }
723
- socket.close(NORMAL_WEBSOCKET_CLOSE_CODE, "finished");
724
- }
725
- if (iterationError) {
726
- throw iterationError;
1510
+ throw error;
727
1511
  }
728
- if (abortRequested || options.signal?.aborted || !follow) {
729
- return;
730
- }
731
- const gracefullyClosed = !sawTransportError && closeCode === NORMAL_WEBSOCKET_CLOSE_CODE;
732
- if (gracefullyClosed) {
733
- return;
734
- }
735
- if (!reconnectEnabled) {
736
- throw new StarciteConnectionError(
737
- `Tail connection dropped for session '${sessionId}' (${describeClose(
738
- closeCode,
739
- closeReason
740
- )})`
741
- );
742
- }
743
- await waitForDelay(reconnectDelayMs, options.signal);
744
1512
  }
745
1513
  }
1514
+ persistLogState() {
1515
+ this.store.save(this.id, {
1516
+ cursor: this.log.cursor,
1517
+ events: [...this.log.events]
1518
+ });
1519
+ }
1520
+ };
1521
+
1522
+ // src/session-store.ts
1523
+ function cloneEvents(events) {
1524
+ return events.map((event) => structuredClone(event));
1525
+ }
1526
+ function cloneState(state) {
1527
+ return {
1528
+ cursor: state.cursor,
1529
+ events: cloneEvents(state.events)
1530
+ };
1531
+ }
1532
+ var MemoryStore = class {
1533
+ sessions = /* @__PURE__ */ new Map();
1534
+ load(sessionId) {
1535
+ const stored = this.sessions.get(sessionId);
1536
+ return stored ? cloneState(stored) : void 0;
1537
+ }
1538
+ save(sessionId, state) {
1539
+ this.sessions.set(sessionId, cloneState(state));
1540
+ }
1541
+ clear(sessionId) {
1542
+ this.sessions.delete(sessionId);
1543
+ }
1544
+ };
1545
+
1546
+ // src/client.ts
1547
+ var DEFAULT_BASE_URL = globalThis.process?.env?.STARCITE_BASE_URL ?? "http://localhost:4000";
1548
+ var DEFAULT_AUTH_URL = globalThis.process?.env?.STARCITE_AUTH_URL;
1549
+ function resolveAuthBaseUrl(explicitAuthUrl, apiKey) {
1550
+ if (explicitAuthUrl) {
1551
+ return normalizeAbsoluteHttpUrl(explicitAuthUrl, "authUrl");
1552
+ }
1553
+ if (DEFAULT_AUTH_URL) {
1554
+ return normalizeAbsoluteHttpUrl(DEFAULT_AUTH_URL, "STARCITE_AUTH_URL");
1555
+ }
1556
+ if (apiKey) {
1557
+ return inferIssuerAuthorityFromApiKey(apiKey);
1558
+ }
1559
+ return void 0;
1560
+ }
1561
+ var Starcite = class {
1562
+ /** Normalized API base URL ending with `/v1`. */
1563
+ baseUrl;
1564
+ transport;
1565
+ authBaseUrl;
1566
+ inferredIdentity;
1567
+ store;
1568
+ constructor(options = {}) {
1569
+ const baseUrl = toApiBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
1570
+ this.baseUrl = baseUrl;
1571
+ const userFetch = options.fetch;
1572
+ const fetchFn = userFetch ? (input, init) => userFetch(input, init) : (input, init) => fetch(input, init);
1573
+ const headers = new Headers(options.headers);
1574
+ const apiKey = options.apiKey?.trim();
1575
+ let authorization;
1576
+ if (apiKey) {
1577
+ authorization = `Bearer ${apiKey}`;
1578
+ this.inferredIdentity = inferIdentityFromApiKey(apiKey);
1579
+ }
1580
+ this.authBaseUrl = resolveAuthBaseUrl(options.authUrl, apiKey);
1581
+ const websocketFactory = options.websocketFactory ?? defaultWebSocketFactory;
1582
+ this.store = options.store ?? new MemoryStore();
1583
+ this.transport = {
1584
+ baseUrl,
1585
+ websocketBaseUrl: toWebSocketBaseUrl(baseUrl),
1586
+ authorization: authorization ?? null,
1587
+ fetchFn,
1588
+ headers,
1589
+ websocketFactory
1590
+ };
1591
+ }
1592
+ /**
1593
+ * Creates a user identity bound to this client's tenant.
1594
+ */
1595
+ user(options) {
1596
+ return new StarciteIdentity({
1597
+ tenantId: this.requireTenantId("user()"),
1598
+ id: options.id,
1599
+ type: "user"
1600
+ });
1601
+ }
746
1602
  /**
747
- * Opens a WebSocket tail stream and yields transformed session events.
1603
+ * Creates an agent identity bound to this client's tenant.
748
1604
  */
749
- async *tailEvents(sessionId, options = {}) {
750
- for await (const rawEvent of this.tailRawEvents(sessionId, options)) {
751
- yield toSessionEvent(rawEvent);
1605
+ agent(options) {
1606
+ return new StarciteIdentity({
1607
+ tenantId: this.requireTenantId("agent()"),
1608
+ id: options.id,
1609
+ type: "agent"
1610
+ });
1611
+ }
1612
+ session(input) {
1613
+ if ("token" in input) {
1614
+ return this.sessionFromToken(input.token, input.logOptions);
752
1615
  }
1616
+ return this.sessionFromIdentity(input);
753
1617
  }
754
- async request(path, init, schema) {
755
- const headers = new Headers(this.headers);
756
- if (!headers.has("content-type")) {
757
- headers.set("content-type", "application/json");
1618
+ /**
1619
+ * Lists sessions from the archive-backed catalog.
1620
+ */
1621
+ listSessions(options = {}, requestOptions) {
1622
+ const parsed = SessionListOptionsSchema.parse(options);
1623
+ const query = new URLSearchParams();
1624
+ if (parsed.limit !== void 0) {
1625
+ query.set("limit", `${parsed.limit}`);
758
1626
  }
759
- if (init.headers) {
760
- const perRequestHeaders = new Headers(init.headers);
761
- for (const [key, value] of perRequestHeaders.entries()) {
762
- headers.set(key, value);
1627
+ if (parsed.cursor !== void 0) {
1628
+ query.set("cursor", parsed.cursor);
1629
+ }
1630
+ if (parsed.metadata !== void 0) {
1631
+ for (const [key, value] of Object.entries(parsed.metadata)) {
1632
+ query.set(`metadata.${key}`, value);
763
1633
  }
764
1634
  }
765
- let response;
766
- try {
767
- response = await this.fetchFn(`${this.baseUrl}${path}`, {
768
- ...init,
769
- headers
1635
+ const suffix = query.size > 0 ? `?${query.toString()}` : "";
1636
+ return request(
1637
+ this.transport,
1638
+ `/sessions${suffix}`,
1639
+ {
1640
+ method: "GET",
1641
+ signal: requestOptions?.signal
1642
+ },
1643
+ SessionListPageSchema
1644
+ );
1645
+ }
1646
+ async sessionFromIdentity(input) {
1647
+ let sessionId = input.id;
1648
+ let record;
1649
+ if (!sessionId) {
1650
+ record = await this.createSession({
1651
+ creator_principal: input.identity.toCreatorPrincipal(),
1652
+ title: input.title,
1653
+ metadata: input.metadata
770
1654
  });
771
- } catch (error) {
772
- const rootCause = toError(error).message;
773
- throw new StarciteConnectionError(
774
- `Failed to connect to Starcite at ${this.baseUrl}: ${rootCause}`
1655
+ sessionId = record.id;
1656
+ }
1657
+ const tokenResponse = await this.issueSessionToken({
1658
+ session_id: sessionId,
1659
+ principal: input.identity.toTokenPrincipal(),
1660
+ scopes: ["session:read", "session:append"]
1661
+ });
1662
+ return new StarciteSession({
1663
+ id: sessionId,
1664
+ token: tokenResponse.token,
1665
+ identity: input.identity,
1666
+ transport: this.buildSessionTransport(tokenResponse.token),
1667
+ store: this.store,
1668
+ record,
1669
+ logOptions: input.logOptions
1670
+ });
1671
+ }
1672
+ sessionFromToken(token, logOptions) {
1673
+ const decoded = decodeSessionToken(token);
1674
+ const sessionId = decoded.sessionId;
1675
+ if (!sessionId) {
1676
+ throw new StarciteError(
1677
+ "session({ token }) requires a token with a session_id claim."
775
1678
  );
776
1679
  }
777
- if (!response.ok) {
778
- const payload = await tryParseJson(response);
779
- const code = typeof payload?.error === "string" ? payload.error : `http_${response.status}`;
780
- const message = typeof payload?.message === "string" ? payload.message : response.statusText;
781
- throw new StarciteApiError(message, response.status, code, payload);
1680
+ return new StarciteSession({
1681
+ id: sessionId,
1682
+ token,
1683
+ identity: decoded.identity,
1684
+ transport: this.buildSessionTransport(token),
1685
+ store: this.store,
1686
+ logOptions
1687
+ });
1688
+ }
1689
+ buildSessionTransport(token) {
1690
+ return {
1691
+ ...this.transport,
1692
+ authorization: `Bearer ${token}`
1693
+ };
1694
+ }
1695
+ createSession(input) {
1696
+ return request(
1697
+ this.transport,
1698
+ "/sessions",
1699
+ {
1700
+ method: "POST",
1701
+ body: JSON.stringify(input)
1702
+ },
1703
+ SessionRecordSchema
1704
+ );
1705
+ }
1706
+ issueSessionToken(input) {
1707
+ if (!this.transport.authorization) {
1708
+ throw new StarciteError(
1709
+ "session() with identity requires apiKey. Set StarciteOptions.apiKey."
1710
+ );
782
1711
  }
783
- if (response.status === 204) {
784
- return void 0;
1712
+ if (!this.authBaseUrl) {
1713
+ throw new StarciteError(
1714
+ "session() could not resolve auth issuer URL. Set StarciteOptions.authUrl, STARCITE_AUTH_URL, or use an API key JWT with an 'iss' claim."
1715
+ );
785
1716
  }
786
- const responseBody = await response.json();
787
- if (!schema) {
788
- return responseBody;
1717
+ return requestWithBaseUrl(
1718
+ this.transport,
1719
+ this.authBaseUrl,
1720
+ "/api/v1/session-tokens",
1721
+ {
1722
+ method: "POST",
1723
+ headers: {
1724
+ "cache-control": "no-store"
1725
+ },
1726
+ body: JSON.stringify(input)
1727
+ },
1728
+ IssueSessionTokenResponseSchema
1729
+ );
1730
+ }
1731
+ requireTenantId(method) {
1732
+ const tenantId = this.inferredIdentity?.tenantId;
1733
+ if (!tenantId) {
1734
+ throw new StarciteError(`${method} requires apiKey to determine tenant.`);
789
1735
  }
790
- const parsed = schema.safeParse(responseBody);
791
- if (!parsed.success) {
792
- const issue = parsed.error.issues[0]?.message ?? "invalid response";
793
- throw new StarciteConnectionError(
794
- `Received unexpected response payload from Starcite: ${issue}`
795
- );
1736
+ return tenantId;
1737
+ }
1738
+ };
1739
+
1740
+ // src/cursor-store.ts
1741
+ var DEFAULT_KEY_PREFIX = "starcite";
1742
+ var InMemoryCursorStore = class {
1743
+ cursors;
1744
+ constructor(initial = {}) {
1745
+ this.cursors = new Map(Object.entries(initial));
1746
+ }
1747
+ load(sessionId) {
1748
+ return this.cursors.get(sessionId);
1749
+ }
1750
+ save(sessionId, cursor) {
1751
+ this.cursors.set(sessionId, cursor);
1752
+ }
1753
+ };
1754
+ var WebStorageCursorStore = class {
1755
+ storage;
1756
+ keyForSession;
1757
+ constructor(storage, options = {}) {
1758
+ this.storage = storage;
1759
+ const prefix = options.keyPrefix?.trim() || DEFAULT_KEY_PREFIX;
1760
+ this.keyForSession = options.keyForSession ?? ((sessionId) => `${prefix}:${sessionId}:lastSeq`);
1761
+ }
1762
+ load(sessionId) {
1763
+ const raw = this.storage.getItem(this.keyForSession(sessionId));
1764
+ if (raw === null) {
1765
+ return void 0;
796
1766
  }
797
- return parsed.data;
1767
+ const parsed = Number.parseInt(raw, 10);
1768
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : void 0;
1769
+ }
1770
+ save(sessionId, cursor) {
1771
+ this.storage.setItem(this.keyForSession(sessionId), `${cursor}`);
798
1772
  }
799
1773
  };
800
- async function tryParseJson(response) {
801
- try {
802
- const parsed = await response.json();
803
- const result = StarciteErrorPayloadSchema.safeParse(parsed);
804
- return result.success ? result.data : null;
805
- } catch {
806
- return null;
1774
+ var LocalStorageCursorStore = class extends WebStorageCursorStore {
1775
+ constructor(options = {}) {
1776
+ if (typeof localStorage === "undefined") {
1777
+ throw new StarciteError(
1778
+ "localStorage is not available in this runtime. Use WebStorageCursorStore with a custom storage adapter."
1779
+ );
1780
+ }
1781
+ super(localStorage, options);
807
1782
  }
808
- }
809
-
810
- // src/index.ts
811
- function createStarciteClient(options = {}) {
812
- return new StarciteClient(options);
813
- }
814
- var starcite = createStarciteClient();
1783
+ };
815
1784
  export {
1785
+ InMemoryCursorStore,
1786
+ LocalStorageCursorStore,
1787
+ MemoryStore,
1788
+ SessionLogConflictError,
1789
+ SessionLogGapError,
1790
+ Starcite,
816
1791
  StarciteApiError,
817
- StarciteClient,
1792
+ StarciteBackpressureError,
818
1793
  StarciteConnectionError,
819
1794
  StarciteError,
1795
+ StarciteIdentity,
1796
+ StarciteRetryLimitError,
820
1797
  StarciteSession,
821
- createStarciteClient,
822
- normalizeBaseUrl,
823
- starcite
1798
+ StarciteTailError,
1799
+ StarciteTokenExpiredError,
1800
+ WebStorageCursorStore
824
1801
  };
825
1802
  //# sourceMappingURL=index.js.map