@astralform/js 5.1.0 → 6.0.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/README.md CHANGED
@@ -265,8 +265,13 @@ const id = await session.createNewConversation();
265
265
  // Switch to an existing conversation (loads messages from backend)
266
266
  await session.switchConversation("conversation-id");
267
267
 
268
- // Delete a conversation
269
- await session.deleteConversation("conversation-id");
268
+ // Delete a conversation. Rejects if the server refused it — a 404 counts as
269
+ // already deleted and resolves, anything else leaves the conversation in place.
270
+ try {
271
+ await session.deleteConversation("conversation-id");
272
+ } catch (err) {
273
+ // Still there: tell the user rather than removing it from your own UI.
274
+ }
270
275
 
271
276
  // Edit and resend from a checkpoint
272
277
  await session.resendFromCheckpoint("message-id", "Updated message");
@@ -357,7 +362,7 @@ import {
357
362
  AuthenticationError, // 401 — invalid API key
358
363
  RateLimitError, // 429 — rate limit exceeded
359
364
  LLMNotConfiguredError, // LLM provider not set up
360
- ServerError, // 5xx or unexpected errors
365
+ ServerError, // 5xx or unexpected errors — carries `status` when it came from a response
361
366
  ConnectionError, // Network failures
362
367
  StreamAbortedError, // Stream cancelled via disconnect()
363
368
  } from "@astralform/js";
package/dist/index.cjs CHANGED
@@ -73,9 +73,10 @@ var LLMNotConfiguredError = class extends AstralformError {
73
73
  }
74
74
  };
75
75
  var ServerError = class extends AstralformError {
76
- constructor(message = "Internal server error") {
76
+ constructor(message = "Internal server error", status) {
77
77
  super(message, "server_error");
78
78
  this.name = "ServerError";
79
+ if (status !== void 0) Object.assign(this, { status });
79
80
  }
80
81
  };
81
82
  var ConnectionError = class extends AstralformError {
@@ -105,6 +106,16 @@ function generateId() {
105
106
  return v.toString(16);
106
107
  });
107
108
  }
109
+ function snakeToCamel(str) {
110
+ return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
111
+ }
112
+ function camelizeKeys(obj) {
113
+ const result = {};
114
+ for (const key of Object.keys(obj)) {
115
+ result[snakeToCamel(key)] = obj[key];
116
+ }
117
+ return result;
118
+ }
108
119
 
109
120
  // src/rate-limit.ts
110
121
  var DEFAULT_MESSAGE = "Rate limit exceeded";
@@ -255,7 +266,7 @@ async function* streamJobSSE(options) {
255
266
  case 429:
256
267
  throw createRateLimitErrorFromHttp(response, rawText);
257
268
  default:
258
- throw new ServerError(text || `HTTP ${response.status}`);
269
+ throw new ServerError(text || `HTTP ${response.status}`, response.status);
259
270
  }
260
271
  }
261
272
  if (!response.body) {
@@ -526,7 +537,10 @@ var AstralformClient = class {
526
537
  throw createRateLimitErrorFromHttp(response, text);
527
538
  default: {
528
539
  const safeText = text ? sanitizeErrorText(text) : "";
529
- throw new ServerError(safeText || `HTTP ${response.status}`);
540
+ throw new ServerError(
541
+ safeText || `HTTP ${response.status}`,
542
+ response.status
543
+ );
530
544
  }
531
545
  }
532
546
  }
@@ -563,13 +577,7 @@ var AstralformClient = class {
563
577
  const safeLimit = Math.max(1, Math.min(200, Math.floor(Number(limit))));
564
578
  const safeOffset = Math.max(0, Math.floor(Number(offset)));
565
579
  const raw = await this.get(`/v1/conversations?limit=${safeLimit}&offset=${safeOffset}`);
566
- return raw.map((c) => ({
567
- id: c.id,
568
- title: c.title,
569
- messageCount: c.message_count,
570
- createdAt: c.created_at,
571
- updatedAt: c.updated_at
572
- }));
580
+ return raw.map((c) => camelizeKeys(c));
573
581
  }
574
582
  async getMessages(conversationId) {
575
583
  const raw = await this.get(`/v1/conversations/${encodeURIComponent(conversationId)}/messages`);
@@ -594,13 +602,7 @@ var AstralformClient = class {
594
602
  */
595
603
  async renameConversation(id, title) {
596
604
  const c = await this.patch(`/v1/conversations/${encodeURIComponent(id)}`, { title });
597
- return {
598
- id: c.id,
599
- title: c.title,
600
- messageCount: c.message_count,
601
- createdAt: c.created_at,
602
- updatedAt: c.updated_at
603
- };
605
+ return camelizeKeys(c);
604
606
  }
605
607
  async deleteConversation(id) {
606
608
  await this.del(`/v1/conversations/${encodeURIComponent(id)}`);
@@ -613,14 +615,7 @@ var AstralformClient = class {
613
615
  */
614
616
  async getAgents() {
615
617
  const raw = await this.get("/v1/agents");
616
- return raw.map((a) => ({
617
- name: a.name,
618
- displayName: a.display_name,
619
- description: a.description,
620
- isOrchestrator: a.is_orchestrator,
621
- isEnabled: a.is_enabled,
622
- avatarUrl: a.avatar_url
623
- }));
618
+ return raw.map((a) => camelizeKeys(a));
624
619
  }
625
620
  /**
626
621
  * List the models the caller may pick this turn — expanded from the curated
@@ -647,12 +642,7 @@ var AstralformClient = class {
647
642
  }
648
643
  async getSkills() {
649
644
  const raw = await this.get("/v1/skills");
650
- return raw.map((s) => ({
651
- name: s.name,
652
- displayName: s.display_name,
653
- description: s.description,
654
- isEnabled: s.is_enabled
655
- }));
645
+ return raw.map((s) => camelizeKeys(s));
656
646
  }
657
647
  async getConversationEvents(conversationId, jobId) {
658
648
  let url = `/v1/conversations/${encodeURIComponent(conversationId)}/events`;
@@ -769,13 +759,7 @@ var AstralformClient = class {
769
759
  // sending them in API-key mode yields 401.
770
760
  async listTeams() {
771
761
  const raw = await this.get("/v1/teams");
772
- return raw.map((t) => ({
773
- id: t.id,
774
- name: t.name,
775
- slug: t.slug,
776
- isDefault: t.is_default,
777
- role: t.role
778
- }));
762
+ return raw.map((t) => camelizeKeys(t));
779
763
  }
780
764
  /**
781
765
  * List the team-level agents (formerly "projects") the signed-in user can
@@ -784,14 +768,7 @@ var AstralformClient = class {
784
768
  */
785
769
  async listAgents(teamId) {
786
770
  const raw = await this.get(`/v1/teams/${encodeURIComponent(teamId)}/agents`);
787
- return raw.map((a) => ({
788
- id: a.id,
789
- name: a.name,
790
- teamId: a.team_id,
791
- createdAt: a.created_at,
792
- updatedAt: a.updated_at,
793
- avatarUrl: a.avatar_url ?? null
794
- }));
771
+ return raw.map((a) => camelizeKeys(a));
795
772
  }
796
773
  // --- Jobs API ---
797
774
  async createJob(request) {
@@ -828,13 +805,7 @@ var AstralformClient = class {
828
805
  };
829
806
  if (request.comment != null) body.comment = request.comment;
830
807
  const raw = await this.post(`/v1/jobs/${encodeURIComponent(jobId)}/feedback`, body);
831
- return {
832
- id: raw.id,
833
- jobId: raw.job_id,
834
- rating: raw.rating,
835
- comment: raw.comment,
836
- createdAt: raw.created_at
837
- };
808
+ return camelizeKeys(raw);
838
809
  }
839
810
  async getActiveJob(conversationId) {
840
811
  const raw = await this.get(`/v1/conversations/${encodeURIComponent(conversationId)}/active-job`);
@@ -2222,11 +2193,12 @@ var ChatSession = class {
2222
2193
  /**
2223
2194
  * Rename a conversation, server first.
2224
2195
  *
2225
- * Deliberately NOT optimistic, unlike the delete below. A failed delete is
2226
- * self-correcting (the row is still there on the next page fetch), but a
2227
- * failed rename that had already been written locally would leave the
2228
- * sidebar showing a title the server never accepted and nothing refetches
2229
- * a conversation that is already in the loaded list.
2196
+ * Server first, like the delete below: a failed rename written locally would
2197
+ * leave the sidebar showing a title the server never accepted, and nothing
2198
+ * refetches a conversation that is already in the loaded list. (The delete
2199
+ * used to be the counter-example hereit dropped the row whatever the
2200
+ * server said, on the theory that a failed one was self-correcting. It was
2201
+ * not: the row stayed deleted locally and alive on the server.)
2230
2202
  *
2231
2203
  * Mirrors the `title_generated` path: the entry in `conversations` is
2232
2204
  * mutated in place, which is what every consumer of the list reads.
@@ -2242,7 +2214,8 @@ var ChatSession = class {
2242
2214
  async deleteConversation(id) {
2243
2215
  try {
2244
2216
  await this.client.deleteConversation(id);
2245
- } catch {
2217
+ } catch (err) {
2218
+ if (!(err instanceof ServerError) || err.status !== 404) throw err;
2246
2219
  }
2247
2220
  await this.storage.deleteConversation(id);
2248
2221
  if (this.serverConversationIds.delete(id)) {
@@ -2268,9 +2241,10 @@ var ChatSession = class {
2268
2241
 
2269
2242
  // src/restore-plan.ts
2270
2243
  function planRestore(args) {
2271
- const { completedJobs, userMessages } = args;
2244
+ const { completedJobs, runningJob, userMessages } = args;
2245
+ const jobs = runningJob ? [...completedJobs, runningJob] : completedJobs;
2272
2246
  const claimed = new Set(
2273
- (args.claimedMessageIds ?? completedJobs.map((j) => j.message_id)).filter(
2247
+ (args.claimedMessageIds ?? jobs.map((j) => j.message_id)).filter(
2274
2248
  (id) => !!id
2275
2249
  )
2276
2250
  );
@@ -2279,19 +2253,19 @@ function planRestore(args) {
2279
2253
  if (m.id) byId.set(m.id, i);
2280
2254
  });
2281
2255
  const linkOf = (j) => j.message_id ? byId.get(j.message_id) : void 0;
2282
- const positional = (jobs, msgs) => jobs.map((job, i) => ({
2256
+ const positional = (slice, msgs) => slice.map((job, i) => ({
2283
2257
  kind: "turn",
2284
2258
  jobId: job.job_id,
2285
2259
  content: msgs[i]?.content,
2286
2260
  messageId: msgs[i]?.id
2287
2261
  }));
2288
- const firstLinked = completedJobs.findIndex((j) => linkOf(j) !== void 0);
2262
+ const firstLinked = jobs.findIndex((j) => linkOf(j) !== void 0);
2289
2263
  if (firstLinked === -1) {
2290
- return positional(completedJobs, userMessages);
2264
+ return positional(jobs, userMessages);
2291
2265
  }
2292
- const cutover = linkOf(completedJobs[firstLinked]);
2266
+ const cutover = linkOf(jobs[firstLinked]);
2293
2267
  const steps = positional(
2294
- completedJobs.slice(0, firstLinked),
2268
+ jobs.slice(0, firstLinked),
2295
2269
  userMessages.slice(0, cutover)
2296
2270
  );
2297
2271
  let cursor = cutover;
@@ -2305,7 +2279,7 @@ function planRestore(args) {
2305
2279
  }
2306
2280
  cursor = stopAt + 1;
2307
2281
  };
2308
- for (const job of completedJobs.slice(firstLinked)) {
2282
+ for (const job of jobs.slice(firstLinked)) {
2309
2283
  const at = linkOf(job);
2310
2284
  if (at !== void 0) {
2311
2285
  drainTo(at);
@@ -2387,6 +2361,13 @@ var StreamManager = class {
2387
2361
  * one the user is waiting on — see ``restore``.
2388
2362
  */
2389
2363
  this.generation = 0;
2364
+ /**
2365
+ * Bumped every time a turn STARTS. `generation` does not move for a send
2366
+ * (only a pointer move does), and the streaming state returns to idle when a
2367
+ * turn ends — so neither can tell a restore that a turn ran inside one of
2368
+ * its awaits. This can.
2369
+ */
2370
+ this.turnCounter = 0;
2390
2371
  this.session = session;
2391
2372
  this.attach();
2392
2373
  }
@@ -2455,6 +2436,7 @@ var StreamManager = class {
2455
2436
  target = await this.session.createNewConversation();
2456
2437
  this.setActiveConversation(target);
2457
2438
  }
2439
+ this.turnCounter++;
2458
2440
  this.setState("streaming");
2459
2441
  try {
2460
2442
  await this.session.send(content, {
@@ -2491,6 +2473,7 @@ var StreamManager = class {
2491
2473
  );
2492
2474
  const lastUserMsg = userMsgs[userMsgs.length - 1];
2493
2475
  if (!lastUserMsg) return;
2476
+ this.turnCounter++;
2494
2477
  this.setState("streaming");
2495
2478
  try {
2496
2479
  await this.session.resendFromCheckpoint(
@@ -2691,7 +2674,9 @@ var StreamManager = class {
2691
2674
  async restore(conversationId, gen) {
2692
2675
  const superseded = () => gen !== this.generation;
2693
2676
  if (superseded()) return;
2694
- if (!this.session.isStreaming) this.setState("restoring");
2677
+ const turn = this.turnCounter;
2678
+ const announcedRestoring = !this.session.isStreaming;
2679
+ if (announcedRestoring) this.setState("restoring");
2695
2680
  let activeJobId = null;
2696
2681
  try {
2697
2682
  const res = await this.session.client.getActiveJob(conversationId);
@@ -2699,9 +2684,12 @@ var StreamManager = class {
2699
2684
  } catch {
2700
2685
  }
2701
2686
  if (superseded()) return;
2687
+ await this.session.loadConversation(conversationId);
2688
+ if (superseded()) return;
2689
+ if (announcedRestoring && this.viewTakenOverByLiveTurn()) return;
2690
+ if (announcedRestoring && !await this.replayHistory(conversationId, gen, activeJobId, turn))
2691
+ return;
2702
2692
  if (activeJobId) {
2703
- await this.session.loadConversation(conversationId);
2704
- if (superseded()) return;
2705
2693
  this.setState("streaming");
2706
2694
  try {
2707
2695
  await this.session.reconnectToJob(activeJobId);
@@ -2712,76 +2700,125 @@ var StreamManager = class {
2712
2700
  this.setState("idle");
2713
2701
  }
2714
2702
  } else {
2715
- await this.session.loadConversation(conversationId);
2716
2703
  if (superseded()) return;
2717
- try {
2718
- const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2719
- if (superseded()) return;
2720
- const completedJobs = jobs.filter(
2721
- (j) => j.status === "completed"
2722
- );
2723
- const userMessages = this.session.messages.filter(
2724
- (m) => m.role === "user"
2725
- );
2726
- const plan = planRestore({
2727
- completedJobs: completedJobs.map((j) => ({
2728
- job_id: j.job_id,
2729
- message_id: j.message_id
2730
- })),
2731
- // Completed jobs PLUS the ones still going. A send landing in the
2732
- // probe window has a running job, so its prompt is claimed and does
2733
- // not read as a steer replayed over the bubble the live send already
2734
- // rendered. Failed and cancelled jobs are deliberately NOT claimed:
2735
- // they produce no `turn` step, so claiming them would delete the
2736
- // user's prompt from the restore entirely rather than show it as a
2737
- // steer.
2738
- claimedMessageIds: jobs.filter((j) => j.status !== "failed" && j.status !== "cancelled").map((j) => j.message_id),
2739
- userMessages: userMessages.map((m) => ({
2740
- id: m.id,
2741
- content: m.content
2742
- }))
2743
- });
2744
- const eventLists = await Promise.all(
2745
- completedJobs.map(
2746
- (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2747
- )
2748
- );
2749
- if (superseded()) return;
2750
- const eventsByJobId = new Map(
2751
- completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2752
- );
2753
- for (const step of plan) {
2754
- if (superseded()) return;
2755
- if (step.kind === "steer") {
2756
- this.session.replayTurn(
2757
- conversationId,
2758
- [],
2759
- step.content,
2760
- step.messageId,
2761
- true
2762
- );
2763
- continue;
2764
- }
2704
+ this.settleIdle();
2705
+ }
2706
+ }
2707
+ /**
2708
+ * Has a live turn taken the block view over?
2709
+ *
2710
+ * ``_state`` is the SYNCHRONOUS authority and ``session.isStreaming`` lags it
2711
+ * by an await: ``send`` sets ``_state = "streaming"`` before its first await,
2712
+ * while the session only raises its flag inside ``processStream``, behind the
2713
+ * ``storage.addMessage`` write. For that whole window a send is underway —
2714
+ * composer cleared, optimistic bubble drawn — and the session flag still
2715
+ * reads false. Reading both closes the window from either end, since
2716
+ * ``reconnectToJob`` is the mirror case: it raises the session flag without
2717
+ * ever moving ``_state``.
2718
+ */
2719
+ viewTakenOverByLiveTurn() {
2720
+ return this._state === "streaming" || this.session.isStreaming;
2721
+ }
2722
+ /**
2723
+ * Has a turn STARTED since ``turn`` was captured?
2724
+ *
2725
+ * ``viewTakenOverByLiveTurn`` reads the current state, so it cannot see a
2726
+ * turn that both started and ENDED inside one of the restore's awaits — a
2727
+ * send that fails fast (auth, rate limit) resolves in about the time the job
2728
+ * list takes, and leaves `_state` back at idle with its blocks already
2729
+ * rendered. A monotonic count is the only thing that survives a state that
2730
+ * has returned to where it started.
2731
+ */
2732
+ turnStarted(since) {
2733
+ return this.turnCounter !== since;
2734
+ }
2735
+ /**
2736
+ * Replay a conversation's persisted history into the consumer's block view.
2737
+ *
2738
+ * Returns false when this restore lost the right to finish — a newer switch
2739
+ * superseded it, or a send took the view over — in which case the caller
2740
+ * must stop rather than finish. See ``restore``.
2741
+ *
2742
+ * ``activeJobId`` names the turn that is still running, if any. Its events
2743
+ * are NOT fetched here: they are the live stream the caller reconnects to
2744
+ * straight after. It is passed so ``planRestore`` can pair it with the prompt
2745
+ * that started it, which is emitted as a bubble with no events — the whole
2746
+ * reason a conversation reopened mid-turn now shows the message that started
2747
+ * that turn.
2748
+ */
2749
+ async replayHistory(conversationId, gen, activeJobId, turn) {
2750
+ const stopReplay = () => gen !== this.generation || this.viewTakenOverByLiveTurn() || this.turnStarted(turn);
2751
+ try {
2752
+ const jobs = await this.session.client.get(`/v1/conversations/${encodeURIComponent(conversationId)}/jobs`);
2753
+ if (stopReplay()) return false;
2754
+ const completedJobs = jobs.filter(
2755
+ (j) => j.status === "completed" && j.job_id !== activeJobId
2756
+ );
2757
+ const userMessages = this.session.messages.filter(
2758
+ (m) => m.role === "user"
2759
+ );
2760
+ const runningJob = activeJobId ? jobs.find((j) => j.job_id === activeJobId) : void 0;
2761
+ const plan = planRestore({
2762
+ completedJobs: completedJobs.map((j) => ({
2763
+ job_id: j.job_id,
2764
+ message_id: j.message_id
2765
+ })),
2766
+ runningJob: runningJob && {
2767
+ job_id: runningJob.job_id,
2768
+ message_id: runningJob.message_id
2769
+ },
2770
+ // Completed jobs PLUS the ones still going. A send landing in the
2771
+ // probe window has a running job, so its prompt is claimed and does
2772
+ // not read as a steer replayed over the bubble the live send already
2773
+ // rendered. Failed and cancelled jobs are deliberately NOT claimed:
2774
+ // they produce no `turn` step, so claiming them would delete the
2775
+ // user's prompt from the restore entirely rather than show it as a
2776
+ // steer.
2777
+ claimedMessageIds: jobs.filter((j) => j.status !== "failed" && j.status !== "cancelled").map((j) => j.message_id),
2778
+ userMessages: userMessages.map((m) => ({
2779
+ id: m.id,
2780
+ content: m.content
2781
+ }))
2782
+ });
2783
+ const eventLists = await Promise.all(
2784
+ completedJobs.map(
2785
+ (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2786
+ )
2787
+ );
2788
+ if (stopReplay()) return false;
2789
+ const eventsByJobId = new Map(
2790
+ completedJobs.map((job, i) => [job.job_id, eventLists[i] ?? []])
2791
+ );
2792
+ for (const step of plan) {
2793
+ if (stopReplay()) return false;
2794
+ if (step.kind === "steer") {
2765
2795
  this.session.replayTurn(
2766
2796
  conversationId,
2767
- eventsByJobId.get(step.jobId) ?? [],
2797
+ [],
2768
2798
  step.content,
2769
- step.messageId
2799
+ step.messageId,
2800
+ true
2770
2801
  );
2802
+ continue;
2771
2803
  }
2772
- if (superseded()) return;
2773
- if (completedJobs.length > 0) {
2774
- this.emit({
2775
- type: "versionsReady",
2776
- conversationId,
2777
- count: completedJobs.length
2778
- });
2779
- }
2780
- } catch {
2804
+ this.session.replayTurn(
2805
+ conversationId,
2806
+ eventsByJobId.get(step.jobId) ?? [],
2807
+ step.content,
2808
+ step.messageId
2809
+ );
2781
2810
  }
2782
- if (superseded()) return;
2783
- this.settleIdle();
2811
+ if (stopReplay()) return false;
2812
+ if (completedJobs.length > 0) {
2813
+ this.emit({
2814
+ type: "versionsReady",
2815
+ conversationId,
2816
+ count: completedJobs.length
2817
+ });
2818
+ }
2819
+ } catch {
2784
2820
  }
2821
+ return !stopReplay();
2785
2822
  }
2786
2823
  // ── Internal: set active conversation ─────────────────────────
2787
2824
  setActiveConversation(id) {