@astralform/js 9.2.0 → 9.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -319,6 +319,11 @@ function isToolOutputStub(value) {
319
319
  const v = value;
320
320
  return v.__stub === "tool_output" && typeof v.call_id === "string" && v.call_id.length > 0;
321
321
  }
322
+ function isToolImageStub(value) {
323
+ if (typeof value !== "object" || value === null) return false;
324
+ const v = value;
325
+ return v.__stub === "tool_image" && typeof v.call_id === "string" && v.call_id.length > 0 && typeof v.index === "number" && Number.isInteger(v.index) && v.index >= 0;
326
+ }
322
327
 
323
328
  // src/client.ts
324
329
  var DEFAULT_BASE_URL = "https://api.astralform.ai";
@@ -442,6 +447,79 @@ var AstralformClient = class {
442
447
  `/v1/code/projects/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`
443
448
  );
444
449
  }
450
+ },
451
+ /**
452
+ * Code groups: a user-named container under one repository, for a feature
453
+ * whose work outlives a single session. Its sessions share one plan and one
454
+ * set of notes, so what one writes the others read.
455
+ *
456
+ * A group is a LABEL, not a binding — unlike a task's project, which is
457
+ * written once on the first turn. A task may join, move between, or leave
458
+ * groups at any time, and a deleted group's sessions survive as ungrouped.
459
+ *
460
+ * The server enforces three things worth knowing before you render a
461
+ * failure: a group that is not this user's answers `404` (never `403`, so a
462
+ * group id cannot be probed); filing a task into a group under a DIFFERENT
463
+ * repository than the task's answers `409`; and the group's plan and notes
464
+ * are deleted with the group, while each session keeps its own.
465
+ */
466
+ groups: {
467
+ /**
468
+ * This user's groups, oldest first. Pass a repository to narrow to one
469
+ * repository's — the composer's picker does; the sidebar asks once and
470
+ * nests them itself.
471
+ */
472
+ list: async (repository) => {
473
+ const query = repository ? `?repository=${encodeURIComponent(repository)}` : "";
474
+ const raw = await this.get(`/v1/code/groups${query}`);
475
+ return raw.map((g) => camelizeKeys(g));
476
+ },
477
+ /**
478
+ * Create a group under a repository. The repository goes through the same
479
+ * normaliser the Projects surface uses, so a pasted GitHub URL is accepted
480
+ * and anything that is not `owner/repo` is refused.
481
+ */
482
+ create: async (repository, title) => {
483
+ const raw = await this.post("/v1/code/groups", { repository, title });
484
+ return camelizeKeys(raw);
485
+ },
486
+ /** Rename a group. A group that is not this user's answers `404`. */
487
+ rename: async (groupId, title) => {
488
+ const raw = await this.patch(`/v1/code/groups/${encodeURIComponent(groupId)}`, { title });
489
+ return camelizeKeys(raw);
490
+ },
491
+ /**
492
+ * Delete a group. Its tasks survive and become ungrouped, each keeping its
493
+ * own plan and notes; the group's SHARED documents are deleted with it.
494
+ */
495
+ remove: async (groupId) => {
496
+ await this.del(`/v1/code/groups/${encodeURIComponent(groupId)}`);
497
+ },
498
+ /**
499
+ * File a task into a group. Idempotent — filing an already-filed task
500
+ * again is the same state, not an error. Refused with `409` when the task
501
+ * belongs to a different repository than the group, because it would
502
+ * render nowhere in the sidebar.
503
+ */
504
+ assign: async (groupId, conversationId) => {
505
+ const raw = await this.post(
506
+ `/v1/code/groups/${encodeURIComponent(groupId)}/tasks/${encodeURIComponent(conversationId)}`,
507
+ void 0
508
+ );
509
+ return camelizeKeys(raw);
510
+ },
511
+ /**
512
+ * Take a task out of its group. The task and its documents are untouched.
513
+ *
514
+ * Deliberately not strict about WHICH group the task is in: a task that is
515
+ * in a different one (or in none) already satisfies "this task should not
516
+ * be grouped", so the call clears it rather than refusing.
517
+ */
518
+ unassign: async (groupId, conversationId) => {
519
+ await this.del(
520
+ `/v1/code/groups/${encodeURIComponent(groupId)}/tasks/${encodeURIComponent(conversationId)}`
521
+ );
522
+ }
445
523
  }
446
524
  };
447
525
  if (isApiKeyConfig(config)) {
@@ -642,6 +720,13 @@ var AstralformClient = class {
642
720
  return await response.json();
643
721
  });
644
722
  }
723
+ /** A GET whose body is binary. Read inside the race, for the reason above. */
724
+ async getBlob(path) {
725
+ return this.withDeadline(async (signal) => {
726
+ const response = await this.send("GET", path, void 0, signal);
727
+ return response.blob();
728
+ });
729
+ }
645
730
  async post(path, body) {
646
731
  return this.withDeadline(async (signal) => {
647
732
  const response = await this.send("POST", path, body, signal);
@@ -861,6 +946,7 @@ var AstralformClient = class {
861
946
  const params = new URLSearchParams();
862
947
  if (jobId) params.set("job_id", jobId);
863
948
  if (options?.toolOutputs === "stub") params.set("tool_outputs", "stub");
949
+ if (options?.toolImages === "stub") params.set("tool_images", "stub");
864
950
  const query = params.toString();
865
951
  return this.get(
866
952
  `/v1/conversations/${encodeURIComponent(conversationId)}/events${query ? `?${query}` : ""}`
@@ -875,6 +961,25 @@ var AstralformClient = class {
875
961
  );
876
962
  return res.output;
877
963
  }
964
+ /**
965
+ * The image behind a {@link ToolImageStub}, as a `Blob` of the preview's bytes.
966
+ *
967
+ * Takes the stub itself and nothing else — the `job_id` scoping that
968
+ * `getToolOutput`'s id form warns about cannot be dropped from a value that
969
+ * carries it. There is deliberately no by-ids form.
970
+ *
971
+ * A `Blob` rather than a URL because the route is authenticated: an `<img
972
+ * src>` cannot send the bearer token, so a browser consumer turns this into
973
+ * an object URL (`URL.createObjectURL`) and revokes it when the image
974
+ * unmounts. The response is `private, immutable`, so a repeat fetch of the
975
+ * same preview is served by the HTTP cache.
976
+ */
977
+ async getToolImage(conversationId, stub) {
978
+ const query = stub.job_id ? `?job_id=${encodeURIComponent(stub.job_id)}` : "";
979
+ return this.getBlob(
980
+ `/v1/conversations/${encodeURIComponent(conversationId)}/tool-output/${encodeURIComponent(stub.call_id)}/images/${stub.index}${query}`
981
+ );
982
+ }
878
983
  async submitToolResult(request) {
879
984
  await this.post("/v1/tool-result", request);
880
985
  }
@@ -2749,6 +2854,12 @@ var StreamManager = class _StreamManager {
2749
2854
  * Defaults to inline, so this is inert until a consumer opts in.
2750
2855
  */
2751
2856
  this.toolOutputs = "inline";
2857
+ /**
2858
+ * Whether restore asks for tool image previews inline or as fetch handles.
2859
+ * Separate from `toolOutputs` because the server keeps them separate — see
2860
+ * `ToolImageStub` — and on the same one-setting-both-waves rule.
2861
+ */
2862
+ this.toolImages = "inline";
2752
2863
  this._state = "idle";
2753
2864
  this._activeConversationId = null;
2754
2865
  this._backgroundJobs = /* @__PURE__ */ new Map();
@@ -2805,6 +2916,25 @@ var StreamManager = class _StreamManager {
2805
2916
  setToolOutputMode(mode) {
2806
2917
  this.toolOutputs = mode;
2807
2918
  }
2919
+ /**
2920
+ * Ask restore for stubbed image previews, fetched on demand.
2921
+ *
2922
+ * The same caution as `setToolOutputMode`, sharper: a consumer that cannot
2923
+ * resolve one (`isToolImageStub`, `client.getToolImage`) finds an `images`
2924
+ * entry with no `url` and draws a broken image where the preview was.
2925
+ */
2926
+ setToolImageMode(mode) {
2927
+ this.toolImages = mode;
2928
+ }
2929
+ /**
2930
+ * The ONE options object both event waves fetch with — the newest page and
2931
+ * every `loadEarlierTurns` page. Built in one place so an opt-in added here
2932
+ * reaches both by construction: two literals at two call sites is how one
2933
+ * wave would silently keep asking for inline payloads.
2934
+ */
2935
+ get eventFetchOptions() {
2936
+ return { toolOutputs: this.toolOutputs, toolImages: this.toolImages };
2937
+ }
2808
2938
  emit(event) {
2809
2939
  for (const handler of this.handlers) {
2810
2940
  try {
@@ -3305,9 +3435,7 @@ var StreamManager = class _StreamManager {
3305
3435
  const boundary = this.oldestDrawnMessageId;
3306
3436
  const eventLists = await Promise.all(
3307
3437
  page.jobs.map(
3308
- (job) => session.client.getConversationEvents(conversationId, job.job_id, {
3309
- toolOutputs: this.toolOutputs
3310
- }).catch(() => [])
3438
+ (job) => session.client.getConversationEvents(conversationId, job.job_id, this.eventFetchOptions).catch(() => [])
3311
3439
  )
3312
3440
  );
3313
3441
  if (stop()) return 0;
@@ -3428,9 +3556,7 @@ var StreamManager = class _StreamManager {
3428
3556
  });
3429
3557
  const eventLists = await Promise.all(
3430
3558
  replayableJobs.map(
3431
- (job) => this.session.client.getConversationEvents(conversationId, job.job_id, {
3432
- toolOutputs: this.toolOutputs
3433
- }).catch(() => [])
3559
+ (job) => this.session.client.getConversationEvents(conversationId, job.job_id, this.eventFetchOptions).catch(() => [])
3434
3560
  )
3435
3561
  );
3436
3562
  if (stopReplay()) return false;
@@ -3578,6 +3704,7 @@ export {
3578
3704
  VOICE_POLISH_MODES,
3579
3705
  generateId,
3580
3706
  isEmbeddedResource,
3707
+ isToolImageStub,
3581
3708
  isToolOutputStub,
3582
3709
  isVoiceLLMMode,
3583
3710
  isVoicePolishMode,