@guuey/agent-client 0.1.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.
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Web (browser / Next.js) host adapters for {@link useAgentInvoke}.
3
+ *
4
+ * Studio builds its bundle via {@link createWebAdapters}. The implementations
5
+ * touch `window.localStorage`, `crypto`, and `fetch` only inside their
6
+ * functions — never at module load — so this file is import-safe under SSR
7
+ * (the functions guard on `typeof window`).
8
+ */
9
+ import type { AgentInvokeAdapters, InvokeRequest, ThreadIdStore } from "./types";
10
+ /**
11
+ * Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
12
+ * SSE stream opens). Carries the pod's structured `{ code, message }` when
13
+ * present — e.g. a `QUOTA_EXCEEDED` 429 whose message ("…reached its plan
14
+ * generation limit…") the chat UI should surface — falling back to the bare
15
+ * status for non-JSON failures.
16
+ */
17
+ export declare class AgentResponseError extends Error {
18
+ readonly status: number;
19
+ readonly code?: string | undefined;
20
+ constructor(message: string, status: number, code?: string | undefined);
21
+ }
22
+ /** Persists the threadId in `window.localStorage` (synchronously). */
23
+ export declare const localStorageThreadStore: ThreadIdStore;
24
+ /** Crypto-strong client-message id, with a non-crypto fallback. */
25
+ export declare function webGenerateId(): string;
26
+ /**
27
+ * Web SSE transport. When `accessToken` is present the pod identifies the
28
+ * caller by their verified Cognito access token (the same identity the
29
+ * history read plane uses, so persisted threads round-trip on reload).
30
+ * Otherwise it falls back to `credentials: "include"`, which round-trips the
31
+ * HttpOnly `guuey_guest` cookie the pod mints for anonymous browser callers.
32
+ * Reads the body via `ReadableStream.getReader()` (browser).
33
+ */
34
+ export declare function fetchStreamTransport(req: InvokeRequest, accessToken?: string | null): AsyncGenerator<string>;
35
+ export interface CreateWebAdaptersOptions {
36
+ /**
37
+ * Public read-plane base (ending in `/v1`) for transcript history. When
38
+ * omitted, no history adapter is installed and reloads start empty.
39
+ */
40
+ apiBaseUrl?: string;
41
+ /**
42
+ * Resolve the caller's Cognito access token (fresh), or `null` when signed
43
+ * out. When a token is present the chat transport AND the history read
44
+ * authenticate as that user, so a reload restores the transcript. Without
45
+ * a token the transport falls back to the guest cookie and history is
46
+ * skipped — the read plane can't identify a cookie-only browser caller
47
+ * (it reads the `x-guuey-guest` header or a Bearer, not the HttpOnly
48
+ * guest cookie), so there is no identity to replay.
49
+ */
50
+ getAccessToken?: () => Promise<string | null>;
51
+ }
52
+ /**
53
+ * Build the web host-adapter bundle for {@link useAgentInvoke}. Pass an
54
+ * access-token resolver (and the read-plane base) to authenticate the chat
55
+ * transport and enable transcript restore on reload; omit them for an
56
+ * anonymous, history-less bundle.
57
+ */
58
+ export declare function createWebAdapters(opts?: CreateWebAdaptersOptions): AgentInvokeAdapters;
59
+ //# sourceMappingURL=web-adapters.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-adapters.d.ts","sourceRoot":"","sources":["../src/web-adapters.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EACV,mBAAmB,EACnB,aAAa,EAEb,aAAa,EACd,MAAM,SAAS,CAAC;AAGjB;;;;;;GAMG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAGzC,QAAQ,CAAC,MAAM,EAAE,MAAM;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM;gBAFtB,OAAO,EAAE,MAAM,EACN,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM,YAAA;CAKzB;AAED,sEAAsE;AACtE,eAAO,MAAM,uBAAuB,EAAE,aAiBrC,CAAC;AAEF,mEAAmE;AACnE,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAED;;;;;;;GAOG;AACH,wBAAuB,oBAAoB,CACzC,GAAG,EAAE,aAAa,EAClB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,GAC1B,cAAc,CAAC,MAAM,CAAC,CAyCxB;AAED,MAAM,WAAW,wBAAwB;IACvC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC/C;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,GAAE,wBAA6B,GAClC,mBAAmB,CA+BrB"}
@@ -0,0 +1,135 @@
1
+ import { fetchThreadHistory } from "./history";
2
+ /**
3
+ * Thrown when the pod returns a non-2xx status on `/agent/invoke` (before any
4
+ * SSE stream opens). Carries the pod's structured `{ code, message }` when
5
+ * present — e.g. a `QUOTA_EXCEEDED` 429 whose message ("…reached its plan
6
+ * generation limit…") the chat UI should surface — falling back to the bare
7
+ * status for non-JSON failures.
8
+ */
9
+ export class AgentResponseError extends Error {
10
+ status;
11
+ code;
12
+ constructor(message, status, code) {
13
+ super(message);
14
+ this.status = status;
15
+ this.code = code;
16
+ this.name = "AgentResponseError";
17
+ }
18
+ }
19
+ /** Persists the threadId in `window.localStorage` (synchronously). */
20
+ export const localStorageThreadStore = {
21
+ load(key) {
22
+ if (typeof window === "undefined")
23
+ return null;
24
+ try {
25
+ return window.localStorage.getItem(key);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ },
31
+ save(key, threadId) {
32
+ if (typeof window === "undefined")
33
+ return;
34
+ try {
35
+ window.localStorage.setItem(key, threadId);
36
+ }
37
+ catch {
38
+ /* private mode / blocked storage — threadId stays in-memory only */
39
+ }
40
+ },
41
+ };
42
+ /** Crypto-strong client-message id, with a non-crypto fallback. */
43
+ export function webGenerateId() {
44
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
45
+ return crypto.randomUUID();
46
+ }
47
+ return `cmid-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
48
+ }
49
+ /**
50
+ * Web SSE transport. When `accessToken` is present the pod identifies the
51
+ * caller by their verified Cognito access token (the same identity the
52
+ * history read plane uses, so persisted threads round-trip on reload).
53
+ * Otherwise it falls back to `credentials: "include"`, which round-trips the
54
+ * HttpOnly `guuey_guest` cookie the pod mints for anonymous browser callers.
55
+ * Reads the body via `ReadableStream.getReader()` (browser).
56
+ */
57
+ export async function* fetchStreamTransport(req, accessToken) {
58
+ const headers = {
59
+ "Content-Type": "application/json",
60
+ Accept: "text/event-stream",
61
+ };
62
+ const init = {
63
+ method: "POST",
64
+ signal: req.signal,
65
+ headers,
66
+ body: JSON.stringify(req.body),
67
+ };
68
+ if (accessToken) {
69
+ headers.Authorization = `Bearer ${accessToken}`;
70
+ }
71
+ else {
72
+ init.credentials = "include";
73
+ }
74
+ const resp = await fetch(req.url, init);
75
+ if (!resp.ok || !resp.body) {
76
+ // Surface a structured pod error ({ code, message }) when present — e.g. a
77
+ // QUOTA_EXCEEDED 429 carries an upgrade message the UI should show. Fall
78
+ // back to the bare status for non-JSON failures.
79
+ const body = await resp.json().catch(() => null);
80
+ let message = `agent responded ${resp.status}`;
81
+ let code;
82
+ if (body !== null && typeof body === "object") {
83
+ if ("message" in body && typeof body.message === "string" && body.message) {
84
+ message = body.message;
85
+ }
86
+ if ("code" in body && typeof body.code === "string") {
87
+ code = body.code;
88
+ }
89
+ }
90
+ throw new AgentResponseError(message, resp.status, code);
91
+ }
92
+ const reader = resp.body.getReader();
93
+ const decoder = new TextDecoder();
94
+ for (;;) {
95
+ const { value, done } = await reader.read();
96
+ if (done)
97
+ break;
98
+ yield decoder.decode(value, { stream: true });
99
+ }
100
+ }
101
+ /**
102
+ * Build the web host-adapter bundle for {@link useAgentInvoke}. Pass an
103
+ * access-token resolver (and the read-plane base) to authenticate the chat
104
+ * transport and enable transcript restore on reload; omit them for an
105
+ * anonymous, history-less bundle.
106
+ */
107
+ export function createWebAdapters(opts = {}) {
108
+ const { apiBaseUrl, getAccessToken } = opts;
109
+ const transport = async function* (req) {
110
+ const token = getAccessToken ? await getAccessToken() : null;
111
+ yield* fetchStreamTransport(req, token);
112
+ };
113
+ const adapters = {
114
+ storage: localStorageThreadStore,
115
+ generateId: webGenerateId,
116
+ transport,
117
+ };
118
+ if (apiBaseUrl && getAccessToken) {
119
+ adapters.history = {
120
+ load: async (threadId) => {
121
+ const token = await getAccessToken();
122
+ // No readable identity → leave the chat empty (skip) rather than
123
+ // `gone`, which would clear the persisted threadId.
124
+ if (!token)
125
+ return { messages: [] };
126
+ return fetchThreadHistory({
127
+ baseUrl: apiBaseUrl,
128
+ threadId,
129
+ requestInit: { headers: { Authorization: `Bearer ${token}` } },
130
+ });
131
+ },
132
+ };
133
+ }
134
+ return adapters;
135
+ }
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@guuey/agent-client",
3
+ "version": "0.1.0",
4
+ "description": "Client SDK for Guuey's agent runtime: the `useAgentInvoke` React hook + pure SSE helpers that speak the /agent/invoke streaming contract, plus the paginated thread-history read plane. Host adapters (storage / id / transport) are injected, so it runs on web (Next) and React Native alike.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "src",
12
+ "!src/**/*.test.ts",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "react-native": "./src/index.ts",
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./react": {
24
+ "react-native": "./src/react.ts",
25
+ "types": "./dist/react.d.ts",
26
+ "import": "./dist/react.js",
27
+ "default": "./dist/react.js"
28
+ }
29
+ },
30
+ "dependencies": {
31
+ "@silverprotocol/core": "0.3.2"
32
+ },
33
+ "peerDependencies": {
34
+ "react": ">=18"
35
+ },
36
+ "devDependencies": {
37
+ "@testing-library/dom": "^10.0.0",
38
+ "@testing-library/react": "^16.3.2",
39
+ "@types/react": "^19.0.0",
40
+ "jsdom": "^25.0.1",
41
+ "react": "^19.0.0",
42
+ "react-dom": "^19.0.0",
43
+ "typescript": "^5.0.0",
44
+ "vitest": "^3.0.0"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "keywords": [
50
+ "guuey",
51
+ "agent",
52
+ "sse",
53
+ "chat",
54
+ "react",
55
+ "client"
56
+ ],
57
+ "homepage": "https://guuey.com",
58
+ "bugs": {
59
+ "url": "https://github.com/loqu-co/guuey/issues"
60
+ },
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/withguuey/guuey-sdks.git",
64
+ "directory": "packages/agent-client"
65
+ },
66
+ "scripts": {
67
+ "build": "tsc -p tsconfig.build.json",
68
+ "dev": "tsc --watch",
69
+ "typecheck": "tsc --noEmit",
70
+ "test": "vitest run",
71
+ "test:watch": "vitest"
72
+ }
73
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Pure block-walk / resource-narrowing helpers for a block-preserving agent
3
+ * transcript — no React, no DOM, so the narrowing logic stays unit-testable in
4
+ * isolation (this package's vitest runs a `node` environment) and can be shared
5
+ * by every host renderer (Studio's `AgentBlocks`, Portal-web's agent chat).
6
+ *
7
+ * The pod's AgJSON wire carries generative-UI payloads on `tool.done` events,
8
+ * which the reducer folds onto `tool-result` blocks. Two channels reach us:
9
+ *
10
+ * 1. **`uiData`** — the MCP-Apps *surface* channel. The pod's Claude facet
11
+ * routes a tool result's `structuredContent` here when the server stamped
12
+ * `_meta.ui`. Any resource here is intended as UI.
13
+ * 2. **`provider-raw` content blocks** — an MCP embedded `resource` content
14
+ * part does NOT survive as a first-class `resource` AgBlock in the Claude
15
+ * facet; it degrades to `{ type:'provider-raw', vendor, raw:<part> }`. So a
16
+ * `ui://` resource can be hiding inside `provider-raw.raw` and must be
17
+ * scanned for defensively.
18
+ *
19
+ * The resource-narrowing (opaque `JsonValue` → typed payload) mirrors the
20
+ * proven `create-agentic-app` web template — structural validation, never a cast.
21
+ */
22
+ import type { AgBlock, AgMessage, JsonValue } from "@silverprotocol/core";
23
+ import type { HistoryCard } from "./types";
24
+
25
+ /** A narrowed MCP embedded UI resource (the `_meta.ui.resource` shape). */
26
+ export interface McpUiResourcePayload {
27
+ uri: string;
28
+ mimeType?: string;
29
+ text?: string;
30
+ blob?: string;
31
+ }
32
+
33
+ /** Narrow an opaque `JsonValue` to a plain (non-array) JSON object. */
34
+ export function isJsonObject(v: JsonValue | undefined): v is { [key: string]: JsonValue } {
35
+ return typeof v === "object" && v !== null && !Array.isArray(v);
36
+ }
37
+
38
+ /**
39
+ * A JSON object → an MCP UI resource, if it has a `uri` plus renderable
40
+ * payload (`text` or base64 `blob`). Returns `undefined` for anything else.
41
+ */
42
+ export function asResourcePayload(v: JsonValue | undefined): McpUiResourcePayload | undefined {
43
+ if (!isJsonObject(v)) return undefined;
44
+ if (typeof v.uri !== "string") return undefined;
45
+ if (typeof v.text !== "string" && typeof v.blob !== "string") return undefined;
46
+ return {
47
+ uri: v.uri,
48
+ ...(typeof v.mimeType === "string" ? { mimeType: v.mimeType } : {}),
49
+ ...(typeof v.text === "string" ? { text: v.text } : {}),
50
+ ...(typeof v.blob === "string" ? { blob: v.blob } : {}),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Does a `tool-result` block's `uiData` carry an MCP embedded UI resource?
56
+ * Accepts the resource inlined directly, or wrapped as `{ resource: {...} }`
57
+ * (the shape an MCP `resource` content part carries). No `ui://` scheme gate
58
+ * here on purpose: `uiData` is the explicit *surface* channel (the server
59
+ * stamped `_meta.ui`), so any resource on it is meant to render.
60
+ */
61
+ export function asUiResource(uiData: JsonValue | undefined): McpUiResourcePayload | undefined {
62
+ if (!isJsonObject(uiData)) return undefined;
63
+ const direct = asResourcePayload(uiData);
64
+ if (direct) return direct;
65
+ return asResourcePayload(uiData.resource);
66
+ }
67
+
68
+ /**
69
+ * Scan a `provider-raw` block's `raw` (the vendor tool_result content part)
70
+ * for a *generative-UI* resource. Unlike {@link asUiResource}, this path IS
71
+ * gated on the `ui://` scheme: `provider-raw` degradation is a lossy catch-all,
72
+ * so a plain file/text resource riding it is NOT a UI to mount — only the
73
+ * mcp-ui `ui://` convention is.
74
+ */
75
+ export function scanProviderRawForUiResource(
76
+ raw: JsonValue | undefined,
77
+ ): McpUiResourcePayload | undefined {
78
+ if (!isJsonObject(raw)) return undefined;
79
+ const candidate =
80
+ raw.resource !== undefined ? asResourcePayload(raw.resource) : asResourcePayload(raw);
81
+ if (!candidate) return undefined;
82
+ return candidate.uri.startsWith("ui://") ? candidate : undefined;
83
+ }
84
+
85
+ /**
86
+ * Extract a mountable UI resource from an opaque AgBlock-shaped `JsonValue`
87
+ * (used for persisted card snapshot parts, which arrive untyped). Dispatches
88
+ * by `block.type`:
89
+ * - `tool-result` → its `uiData` surface channel
90
+ * - `provider-raw` → a `ui://` resource hiding in `raw`
91
+ * - `resource` → a first-class embedded resource (gated on `ui://`)
92
+ * Everything else → `undefined`.
93
+ */
94
+ export function blockUiResource(block: JsonValue): McpUiResourcePayload | undefined {
95
+ if (!isJsonObject(block)) return undefined;
96
+ switch (block.type) {
97
+ case "tool-result":
98
+ return asUiResource(block.uiData);
99
+ case "provider-raw":
100
+ return scanProviderRawForUiResource(block.raw);
101
+ case "resource": {
102
+ const r = asResourcePayload(block.resource);
103
+ return r && r.uri.startsWith("ui://") ? r : undefined;
104
+ }
105
+ default:
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * A live `tool-result` AgBlock → its mountable UI resource, checking BOTH
112
+ * channels the Claude facet uses:
113
+ * 1. the `uiData` surface channel (server stamped `_meta.ui`), and
114
+ * 2. an embedded `ui://` resource that degraded into a `provider-raw`
115
+ * content part inside the tool result (MCP `resource` parts do NOT survive
116
+ * as first-class `resource` AgBlocks here).
117
+ * First-class `resource` content parts are intentionally not scanned in this
118
+ * typed live path (the Claude facet never emits them); the untyped card path
119
+ * ({@link blockUiResource}) covers them for other facets' persisted snapshots.
120
+ */
121
+ export function toolResultUiResource(
122
+ block: Extract<AgBlock, { type: "tool-result" }>,
123
+ ): McpUiResourcePayload | undefined {
124
+ const fromUiData = asUiResource(block.uiData);
125
+ if (fromUiData) return fromUiData;
126
+ for (const part of block.content) {
127
+ if (part.type === "provider-raw") {
128
+ const found = scanProviderRawForUiResource(part.raw);
129
+ if (found) return found;
130
+ }
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ /**
136
+ * A persisted `HistoryCard`'s `cardSnapshot` → a mountable UI resource. The
137
+ * snapshot is the verbatim `AgArtifact` the pod stored (`{ parts: AgBlock[] }`),
138
+ * so walk its `parts` for the first block that yields a resource; fall back to
139
+ * treating the snapshot root itself as a block.
140
+ *
141
+ * NOTE (`no-ggui-tools`): a ggui-rendered card carries NO inline HTML resource —
142
+ * its UI rides `_meta.ggui.bootstrap` and mounts via `@ggui-ai/react`'s
143
+ * `McpAppIframe`. That branch is OUT OF SCOPE for v1 (deferred-pending-capture).
144
+ * So a real ggui card resolves to `undefined` here and renders as the host's
145
+ * coherent placeholder, not a broken mount.
146
+ */
147
+ export function cardUiResource(cardSnapshot: JsonValue): McpUiResourcePayload | undefined {
148
+ if (!isJsonObject(cardSnapshot)) return undefined;
149
+ const parts = cardSnapshot.parts;
150
+ if (Array.isArray(parts)) {
151
+ for (const part of parts) {
152
+ const found = blockUiResource(part);
153
+ if (found) return found;
154
+ }
155
+ }
156
+ return blockUiResource(cardSnapshot);
157
+ }
158
+
159
+ /**
160
+ * The resource's HTML: inline `text` wins; else base64-decode `blob`. `atob`
161
+ * alone yields a Latin-1 string (mojibake on multibyte UTF-8), so decode via
162
+ * bytes + `TextDecoder`. Invalid base64 → `undefined` (no renderable payload).
163
+ */
164
+ export function resourceHtml(resource: McpUiResourcePayload): string | undefined {
165
+ if (resource.text !== undefined) return resource.text;
166
+ if (resource.blob !== undefined) {
167
+ try {
168
+ return new TextDecoder().decode(Uint8Array.from(atob(resource.blob), (c) => c.charCodeAt(0)));
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ }
173
+ return undefined;
174
+ }
175
+
176
+ /**
177
+ * The tool name for a `tool-result` block, read off its paired `tool-call`
178
+ * block in the same message (the reducer keeps both in one message's content).
179
+ * Falls back to `"tool"` when the pair is missing.
180
+ */
181
+ export function toolNameFor(message: AgMessage, toolCallId: string): string {
182
+ for (const b of message.content) {
183
+ if (b.type === "tool-call" && b.toolCallId === toolCallId) return b.name;
184
+ }
185
+ return "tool";
186
+ }
187
+
188
+ /**
189
+ * Persisted cards, ascending by transcript `seq` (stable; input untouched).
190
+ * These are PRIOR-turn cards — they always precede the live fold, so a
191
+ * block-preserving renderer surfaces them first (e.g. under an "Earlier in
192
+ * this conversation" divider).
193
+ */
194
+ export function sortHistoryCards(cards: readonly HistoryCard[]): HistoryCard[] {
195
+ return [...cards].sort((a, b) => a.seq - b.seq);
196
+ }
package/src/blocks.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Frame → AgEvent[] ingestion for the opt-in block-preserving transcript.
3
+ *
4
+ * The wire carries ONE `message` frame per SSE event, but the *payload* shape
5
+ * differs by producer:
6
+ *
7
+ * - the deployed pod (`nocode-runtime`) emits a SINGLE AgEvent JSON OBJECT per
8
+ * frame (`sendEvent(res, 'message', e)`);
9
+ * - the CLI dev server (`guuey dev --serve`) batches an AgEvent[] ARRAY per
10
+ * frame (`sendEvent(res, 'message', batch)`).
11
+ *
12
+ * `ingestMessageFrame` is tolerant of BOTH shapes (object OR array). Validation
13
+ * is delegated to `@silverprotocol/core`'s `ingestAgEvents`, the library's own
14
+ * parse-known-else-skip consumer validator: any element that is not a valid
15
+ * AgJSON event — including bypass-mode SDKMessage shapes (`type: "assistant"` /
16
+ * `type: "result"`, which are NOT AgEvent `type` literals) — is dropped rather
17
+ * than crashing the stream or forcing a type lie. A frame that is not even a
18
+ * JSON value (`undefined`, a function, `NaN`, …) yields `[]`.
19
+ *
20
+ * The `unknown` → `JsonValue` narrowing is done at runtime via the exported
21
+ * `JsonValue` schema's `safeParse` (the same posture the Claude facet uses for
22
+ * provider-raw parts) — no assertion, no `as`.
23
+ */
24
+ import { ingestAgEvents, JsonValue, type AgEvent } from "@silverprotocol/core";
25
+
26
+ /** Ingest one `message` SSE payload (object OR array) into validated AgEvents. */
27
+ export function ingestMessageFrame(raw: unknown): AgEvent[] {
28
+ const parsed = JsonValue.safeParse(raw);
29
+ if (!parsed.success) return [];
30
+ const value = parsed.data;
31
+ return ingestAgEvents(Array.isArray(value) ? value : [value]);
32
+ }
package/src/history.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Shared transcript-history reader for the base-platform chat client.
3
+ *
4
+ * Reads a thread's persisted transcript from the public read plane
5
+ * (`GET {baseUrl}/threads/{id}/messages`, paginated by `nextToken`) so a
6
+ * reload can repaint history before any SSE traffic starts. Host-agnostic:
7
+ * the caller supplies the base URL and a `requestInit` carrying whatever
8
+ * identity that host can present (a `Authorization: Bearer` header on web /
9
+ * RN when signed in, an `x-guuey-guest` header for RN guests, cookies via
10
+ * `credentials: "include"`). Consumed by {@link createWebAdapters}; Portal
11
+ * has its own copy today and can migrate onto this later.
12
+ */
13
+ import type { JsonValue } from "@silverprotocol/core";
14
+ import type { AgentMessage, HistoryCard, HistoryLoadResult } from "./types";
15
+
16
+ /** One row of `GET /v1/threads/:id/messages`. */
17
+ export interface ThreadHistoryRow {
18
+ seq: number;
19
+ at: string;
20
+ kind: string;
21
+ authorRole: string;
22
+ text: string | null;
23
+ /**
24
+ * The verbatim persisted `AgArtifact` on `kind === "card"` rows; `null` or
25
+ * absent on text/event rows (the read plane omits it there). Forwarded
26
+ * opaquely — never re-parsed into AgEvents.
27
+ */
28
+ cardSnapshot?: JsonValue | null;
29
+ }
30
+
31
+ interface ThreadMessagesResponse {
32
+ rows: ThreadHistoryRow[];
33
+ nextToken: string | null;
34
+ }
35
+
36
+ /** Rows requested per history page. */
37
+ const HISTORY_PAGE_LIMIT = 100;
38
+
39
+ /**
40
+ * Hard bound on `nextToken` pagination: 10 pages × 100 rows = 1000 messages.
41
+ * The server pages in ASCENDING seq order and the newest turns arrive on the
42
+ * LAST pages, so we follow `nextToken` to completion within this cap rather
43
+ * than stopping at page 1 (which would drop exactly the turns a resuming user
44
+ * cares about). A >1000-message thread truncates its tail — accepted here;
45
+ * a server-side `sort=desc`/`from`-seq param would be the fix if it matters.
46
+ */
47
+ const MAX_HISTORY_PAGES = 10;
48
+
49
+ /** Project raw rows to chat turns: text rows only, author → role. */
50
+ export function threadHistoryRowsToMessages(rows: ThreadHistoryRow[]): AgentMessage[] {
51
+ const messages: AgentMessage[] = [];
52
+ for (const row of rows) {
53
+ if (row.kind !== "text" || row.text == null) continue;
54
+ messages.push({ role: row.authorRole === "user" ? "user" : "assistant", text: row.text });
55
+ }
56
+ return messages;
57
+ }
58
+
59
+ /**
60
+ * Project raw rows to persisted generative-UI cards: `kind === "card"` rows
61
+ * that actually carry a snapshot, tagged with their transcript position. The
62
+ * additive counterpart to {@link threadHistoryRowsToMessages} — a
63
+ * block-preserving consumer merges both by `seq`.
64
+ */
65
+ export function threadHistoryRowsToCards(rows: ThreadHistoryRow[]): HistoryCard[] {
66
+ const cards: HistoryCard[] = [];
67
+ for (const row of rows) {
68
+ if (row.kind !== "card" || row.cardSnapshot == null) continue;
69
+ cards.push({ seq: row.seq, at: row.at, cardSnapshot: row.cardSnapshot });
70
+ }
71
+ return cards;
72
+ }
73
+
74
+ export interface ThreadHistoryFetchOptions {
75
+ /** Public read-plane base, already ending in `/v1`. */
76
+ baseUrl: string;
77
+ threadId: string;
78
+ /** Per-request init merged into each page fetch (headers, credentials). */
79
+ requestInit?: RequestInit;
80
+ /**
81
+ * Opt-in: ALSO project `kind === "card"` rows into `result.cards` (see
82
+ * {@link HistoryLoadResult}). Off by default so text-only consumers get the
83
+ * byte-identical `{ messages }` shape. On, a block-preserving renderer gets
84
+ * the persisted cards to interleave by `seq`.
85
+ */
86
+ includeCards?: boolean;
87
+ /** Injection seam for tests; defaults to the global `fetch`. */
88
+ fetchImpl?: typeof fetch;
89
+ }
90
+
91
+ /**
92
+ * Fetch a thread's transcript across all pages. Returns `{ gone: true }` on
93
+ * 403/404 (a stale local threadId the caller no longer owns / that no longer
94
+ * exists) so the hook can drop the persisted id; throws on any other non-OK
95
+ * status so `useAgentInvoke`'s best-effort caller can swallow it and leave
96
+ * the chat empty.
97
+ */
98
+ export async function fetchThreadHistory({
99
+ baseUrl,
100
+ threadId,
101
+ requestInit,
102
+ includeCards = false,
103
+ fetchImpl = fetch,
104
+ }: ThreadHistoryFetchOptions): Promise<HistoryLoadResult> {
105
+ const routeUrl = `${baseUrl}/threads/${encodeURIComponent(threadId)}/messages`;
106
+ const rows: ThreadHistoryRow[] = [];
107
+ let nextToken: string | null = null;
108
+
109
+ for (let page = 0; page < MAX_HISTORY_PAGES; page++) {
110
+ const url =
111
+ `${routeUrl}?limit=${HISTORY_PAGE_LIMIT}` +
112
+ (nextToken ? `&nextToken=${encodeURIComponent(nextToken)}` : "");
113
+ const res = await fetchImpl(url, requestInit);
114
+ if (res.status === 403 || res.status === 404) return { gone: true };
115
+ if (!res.ok) throw new Error(`history load failed: ${res.status}`);
116
+ const body: ThreadMessagesResponse = await res.json();
117
+ rows.push(...body.rows);
118
+ nextToken = body.nextToken;
119
+ if (!nextToken) break;
120
+ }
121
+
122
+ return {
123
+ messages: threadHistoryRowsToMessages(rows),
124
+ ...(includeCards ? { cards: threadHistoryRowsToCards(rows) } : {}),
125
+ };
126
+ }
package/src/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ export {
2
+ parseSseEvents,
3
+ extractAssistantText,
4
+ reduceAssistantText,
5
+ stringField,
6
+ type ParsedSseEvent,
7
+ } from "./sse";
8
+ export {
9
+ createWebAdapters,
10
+ localStorageThreadStore,
11
+ webGenerateId,
12
+ fetchStreamTransport,
13
+ AgentResponseError,
14
+ type CreateWebAdaptersOptions,
15
+ } from "./web-adapters";
16
+ export {
17
+ fetchThreadHistory,
18
+ threadHistoryRowsToMessages,
19
+ threadHistoryRowsToCards,
20
+ type ThreadHistoryRow,
21
+ type ThreadHistoryFetchOptions,
22
+ } from "./history";
23
+ export { ingestMessageFrame } from "./blocks";
24
+ // Pure block-walk / resource-narrowing helpers for a block-preserving renderer
25
+ // (shared by Studio's `AgentBlocks` and Portal-web's agent chat). React-free.
26
+ export {
27
+ asResourcePayload,
28
+ asUiResource,
29
+ blockUiResource,
30
+ cardUiResource,
31
+ isJsonObject,
32
+ resourceHtml,
33
+ scanProviderRawForUiResource,
34
+ sortHistoryCards,
35
+ toolNameFor,
36
+ toolResultUiResource,
37
+ type McpUiResourcePayload,
38
+ } from "./block-ui";
39
+ // Re-export the AgJSON types the block-preserving transcript surfaces, so
40
+ // consumers can name `reduceResult` / block types without a direct
41
+ // `@silverprotocol/core` import.
42
+ export type { AgEvent, AgReduceResult, AgMessage, AgBlock } from "@silverprotocol/core";
43
+ export type {
44
+ AgentMessage,
45
+ HistoryCard,
46
+ ThreadIdStore,
47
+ GenerateId,
48
+ InvokeRequest,
49
+ InvokeTransport,
50
+ AgentInvokeAdapters,
51
+ AgentInvokeHistoryAdapter,
52
+ HistoryLoadResult,
53
+ UseAgentInvokeOptions,
54
+ UseAgentInvokeReturn,
55
+ } from "./types";