@astralform/js 3.2.0 → 4.0.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/README.md CHANGED
@@ -283,11 +283,10 @@ session.toggleClientTool("mcp_get_current_time"); // returns true if now enabled
283
283
  session.enabledClientTools; // Set<string>
284
284
  ```
285
285
 
286
- Platform-level features (web search, plan mode) are enabled per-request via the `send` options:
286
+ Platform-level features (e.g. plan mode) are enabled per-request via the `send` options. Web search needs no option: when the agent's search feature is enabled server-side, the agent decides per-task whether to search.
287
287
 
288
288
  ```ts
289
289
  await session.send("Research the latest on WebGPU", {
290
- enableSearch: true,
291
290
  planMode: true,
292
291
  });
293
292
  ```
package/dist/index.cjs CHANGED
@@ -638,6 +638,10 @@ var AstralformClient = class {
638
638
  workspacePath: raw.workspace_path,
639
639
  sourceMessageId: raw.source_message_id,
640
640
  agentName: raw.agent_name,
641
+ // The API serializes an unsigned asset as `url: null`; normalize to
642
+ // undefined so it matches the `url?: string` type and consumers that
643
+ // check `!== undefined` never receive a null.
644
+ url: raw.url ?? void 0,
641
645
  createdAt: raw.created_at
642
646
  };
643
647
  }
@@ -1343,7 +1347,6 @@ var ChatSession = class {
1343
1347
  ),
1344
1348
  upload_ids: options?.uploadIds,
1345
1349
  agent_name: options?.agentName,
1346
- enable_search: options?.enableSearch,
1347
1350
  plan_mode: options?.planMode,
1348
1351
  // Per-request model choice (client-side model selection).
1349
1352
  provider: options?.provider,
@@ -1353,15 +1356,14 @@ var ChatSession = class {
1353
1356
  };
1354
1357
  await this.processStream(request);
1355
1358
  }
1356
- async resendFromCheckpoint(messageId, newContent, options) {
1359
+ async resendFromCheckpoint(messageId, newContent) {
1357
1360
  if (this.isStreaming) return;
1358
1361
  const request = {
1359
1362
  message: newContent,
1360
1363
  conversation_id: this.conversationId ?? void 0,
1361
1364
  resend_from: messageId,
1362
1365
  mcp_manifest: this.toolRegistry.getManifest(),
1363
- enabled_mcp: Array.from(this.enabledClientTools),
1364
- enable_search: options?.enableSearch
1366
+ enabled_mcp: Array.from(this.enabledClientTools)
1365
1367
  };
1366
1368
  await this.processStream(request);
1367
1369
  }
@@ -1552,6 +1554,20 @@ var ChatSession = class {
1552
1554
  }
1553
1555
  }
1554
1556
  }
1557
+ /**
1558
+ * Synchronous replay of a single stored wire event — the side-effect +
1559
+ * translate + emit core of ``dispatchWireEvent`` without the (live-only)
1560
+ * client-tool round-trip. Called in a tight synchronous loop during history
1561
+ * restore so the consumer's per-event store writes batch into ONE render
1562
+ * instead of re-typing the whole conversation event by event.
1563
+ */
1564
+ replayWireEvent(wire, conversationId) {
1565
+ this.applyWireSideEffects(wire, conversationId, "");
1566
+ const event = translateWireEvent(wire);
1567
+ if (event) {
1568
+ this.emit(event);
1569
+ }
1570
+ }
1555
1571
  /**
1556
1572
  * State mutations driven by wire events. Kept separate from translation so
1557
1573
  * the pure wire → ChatEvent mapping can live in translate.ts and be reused
@@ -1698,34 +1714,57 @@ var ChatSession = class {
1698
1714
  this.messages = [];
1699
1715
  return id;
1700
1716
  }
1701
- async switchConversation(id, jobId, userMessageContent) {
1717
+ /**
1718
+ * Replay one completed turn's already-fetched events, synchronously.
1719
+ *
1720
+ * Fetching is the caller's job (``StreamManager.restore`` loads every turn's
1721
+ * events in parallel and the message list once), so this is pure replay: no
1722
+ * awaits, so the whole restore runs in a single synchronous pass and the
1723
+ * consumer batches it into one render.
1724
+ *
1725
+ * ``userMessageContent`` is the prompt that triggered this turn. It's emitted
1726
+ * as a synthetic ``user_message`` BEFORE any of the turn's events: user
1727
+ * prompts aren't persisted in ``job_events``, and some events precede
1728
+ * ``message_start`` in the stream (e.g. ``memory_recall`` from prompt prep),
1729
+ * so leading with the prompt keeps the turn in order.
1730
+ */
1731
+ replayTurn(id, events, userMessageContent) {
1702
1732
  this.conversationId = id;
1703
1733
  this.resetStreamingState();
1734
+ if (userMessageContent) {
1735
+ this.emit({ type: "user_message", content: userMessageContent });
1736
+ }
1737
+ for (const ev of events) {
1738
+ const type = ev.data.type || ev.event;
1739
+ if (!type || type === "done") continue;
1740
+ const wire = { ...ev.data, type };
1741
+ try {
1742
+ this.replayWireEvent(wire, id);
1743
+ } catch {
1744
+ }
1745
+ }
1746
+ }
1747
+ /**
1748
+ * Load a conversation's messages and replay its persisted history.
1749
+ *
1750
+ * Convenience for plain-``ChatSession`` consumers (the documented
1751
+ * conversation-management API). ``StreamManager`` drives restore itself —
1752
+ * loading messages once and replaying each turn in parallel — and does NOT
1753
+ * call this; it's kept so direct-Session usage doesn't break.
1754
+ *
1755
+ * Without ``jobId`` it replays the whole conversation; with one, just that
1756
+ * job's events.
1757
+ */
1758
+ async switchConversation(id, jobId) {
1704
1759
  const [messagesResult, eventsResult] = await Promise.allSettled([
1705
1760
  this.client.getMessages(id).catch(() => this.storage.fetchMessages(id)),
1706
1761
  this.client.getConversationEvents(id, jobId)
1707
1762
  ]);
1708
1763
  this.messages = messagesResult.status === "fulfilled" ? messagesResult.value : [];
1709
- if (eventsResult.status === "fulfilled") {
1710
- if (userMessageContent) {
1711
- this.emit({ type: "user_message", content: userMessageContent });
1712
- }
1713
- for (const ev of eventsResult.value) {
1714
- const type = ev.data.type || ev.event;
1715
- if (!type || type === "done") continue;
1716
- const wire = { ...ev.data, type };
1717
- try {
1718
- await this.dispatchWireEvent(
1719
- wire,
1720
- id,
1721
- "",
1722
- false
1723
- // don't execute client tools on replay
1724
- );
1725
- } catch {
1726
- }
1727
- }
1728
- }
1764
+ this.replayTurn(
1765
+ id,
1766
+ eventsResult.status === "fulfilled" ? eventsResult.value : []
1767
+ );
1729
1768
  }
1730
1769
  async deleteConversation(id) {
1731
1770
  try {
@@ -1869,7 +1908,6 @@ var StreamManager = class {
1869
1908
  this.setState("streaming");
1870
1909
  try {
1871
1910
  await this.session.send(content, {
1872
- enableSearch: options?.enableSearch,
1873
1911
  agentName: options?.agentName,
1874
1912
  uploadIds: options?.uploadIds,
1875
1913
  planMode: options?.planMode,
@@ -1901,8 +1939,26 @@ var StreamManager = class {
1901
1939
  this.finalizeStream();
1902
1940
  }
1903
1941
  // ── Switch conversation ───────────────────────────────────────
1904
- async switchTo(conversationId) {
1942
+ /**
1943
+ * Switch the active conversation.
1944
+ *
1945
+ * ``opts.skipHistoryReplay`` is a fast path for consumers that CACHE a
1946
+ * restored conversation's rendered blocks: it moves the active pointer and
1947
+ * loads the message list (needed for send / regenerate context) but skips
1948
+ * the expensive event fetch + replay, and never enters the ``restoring``
1949
+ * state, so a consumer that clears its block view on ``restoring`` keeps
1950
+ * showing the cached history with no flash of a spinner.
1951
+ *
1952
+ * It still confirms there is no live job before skipping: the in-memory
1953
+ * background-job map is empty on a fresh instance (page reload) and blind to
1954
+ * jobs started in another tab/device, so the fast path always asks the server
1955
+ * (``getActiveJob``) and falls through to a full reconnect if one is running.
1956
+ * That one small request is the only cost it doesn't skip, so passing the
1957
+ * flag whenever you hold cached blocks is safe.
1958
+ */
1959
+ async switchTo(conversationId, opts) {
1905
1960
  if (conversationId === this._activeConversationId) return;
1961
+ const targetHadBackgroundJob = this._backgroundJobs.has(conversationId);
1906
1962
  if (this._state === "streaming") {
1907
1963
  const oldConvId = this._activeConversationId;
1908
1964
  const jobId = this.session.currentJobId;
@@ -1923,6 +1979,18 @@ var StreamManager = class {
1923
1979
  });
1924
1980
  }
1925
1981
  this.setActiveConversation(conversationId);
1982
+ if (opts?.skipHistoryReplay && !targetHadBackgroundJob) {
1983
+ let activeJobId = null;
1984
+ try {
1985
+ activeJobId = (await this.session.client.getActiveJob(conversationId)).jobId;
1986
+ } catch {
1987
+ }
1988
+ if (!activeJobId) {
1989
+ await this.session.loadConversation(conversationId);
1990
+ this.setState("idle");
1991
+ return;
1992
+ }
1993
+ }
1926
1994
  await this.restore(conversationId);
1927
1995
  }
1928
1996
  // ── Create / delete conversation ──────────────────────────────
@@ -1987,13 +2055,16 @@ var StreamManager = class {
1987
2055
  const userMessages = this.session.messages.filter(
1988
2056
  (m) => m.role === "user"
1989
2057
  );
2058
+ const eventLists = await Promise.all(
2059
+ completedJobs.map(
2060
+ (job) => this.session.client.getConversationEvents(conversationId, job.job_id).catch(() => [])
2061
+ )
2062
+ );
1990
2063
  for (let i = 0; i < completedJobs.length; i++) {
1991
- const job = completedJobs[i];
1992
- const userContent = userMessages[i]?.content;
1993
- await this.session.switchConversation(
2064
+ this.session.replayTurn(
1994
2065
  conversationId,
1995
- job.job_id,
1996
- userContent
2066
+ eventLists[i] ?? [],
2067
+ userMessages[i]?.content
1997
2068
  );
1998
2069
  }
1999
2070
  if (completedJobs.length > 0) {