clark-platform-client-js 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,209 @@
1
+ import { parseSSEStream } from "../sse.js";
2
+
3
+ /**
4
+ * @typedef {Object} ChatMessageTextPart
5
+ * @property {"text"} type
6
+ * @property {string} text
7
+ *
8
+ * @typedef {Object} ChatMessage
9
+ * @property {"system"|"user"|"assistant"|"tool"|string} role
10
+ * @property {string|ChatMessageTextPart[]|null} [content]
11
+ * @property {string} [name]
12
+ * @property {string} [tool_call_id]
13
+ * @property {Array<Object>} [tool_calls]
14
+ *
15
+ * @typedef {Object} CreateChatCompletionParams
16
+ * @property {string} model Bare tier id or "tier:option". Use "clark-code" (or "clark-code:<option>") only via `createPassthrough`/`streamPassthrough`.
17
+ * @property {string} [tierModelId]
18
+ * @property {ChatMessage[]} messages
19
+ * @property {string} [conversationId]
20
+ * @property {"user"|"conversation"} [memoryScope]
21
+ * @property {string} [previousResponseId]
22
+ * @property {{include_usage?: boolean}} [streamOptions]
23
+ * @property {Record<string, unknown>} [metadata]
24
+ * @property {number} [temperature]
25
+ * @property {number} [topP]
26
+ * @property {number} [maxTokens]
27
+ * @property {string|string[]} [stop]
28
+ * @property {AbortSignal} [signal]
29
+ *
30
+ * @typedef {CreateChatCompletionParams} CreatePassthroughChatCompletionParams
31
+ * @property {Array<Object>} [tools]
32
+ * @property {string|Object} [toolChoice]
33
+ * @property {boolean} [parallelToolCalls]
34
+ * @property {"minimal"|"low"|"medium"|"high"|"xhigh"} [reasoningEffort]
35
+ *
36
+ * @typedef {Object} ChatCompletionChoice
37
+ * @property {number} index
38
+ * @property {{role: "assistant", content: string}} message
39
+ * @property {"stop"|"error"|"timeout"|string} finish_reason
40
+ *
41
+ * @typedef {Object} ChatCompletionObject
42
+ * @property {string} id
43
+ * @property {"chat.completion"} object
44
+ * @property {number} created
45
+ * @property {string} model
46
+ * @property {ChatCompletionChoice[]} choices
47
+ * @property {import("./responses.js").PublicUsage} [usage]
48
+ * @property {import("./responses.js").PublicResponseMetadata} [clark]
49
+ *
50
+ * @typedef {Object} ChatCompletionChunk
51
+ * @property {string} id
52
+ * @property {"chat.completion.chunk"} object
53
+ * @property {number} created
54
+ * @property {string} model
55
+ * @property {Array<{index: number, delta: Object, finish_reason: string|null}>} choices
56
+ * @property {import("./responses.js").PublicUsage|null} [usage]
57
+ * @property {import("./responses.js").PublicResponseMetadata|null} [clark]
58
+ */
59
+
60
+ /**
61
+ * @param {CreateChatCompletionParams & CreatePassthroughChatCompletionParams & Record<string, unknown>} params
62
+ * @param {boolean} stream
63
+ */
64
+ function buildChatBody(params, stream) {
65
+ const body = {
66
+ model: params.model,
67
+ messages: params.messages,
68
+ stream,
69
+ };
70
+
71
+ const tierModelId = params.tierModelId ?? params.tier_model_id;
72
+ if (tierModelId !== undefined) body.tier_model_id = tierModelId;
73
+
74
+ const conversationId = params.conversationId ?? params.conversation_id;
75
+ if (conversationId !== undefined) body.conversation_id = conversationId;
76
+
77
+ const memoryScope = params.memoryScope ?? params.memory_scope;
78
+ if (memoryScope !== undefined) body.memory_scope = memoryScope;
79
+
80
+ const previousResponseId = params.previousResponseId ?? params.previous_response_id;
81
+ if (previousResponseId !== undefined) body.previous_response_id = previousResponseId;
82
+
83
+ const streamOptions = params.streamOptions ?? params.stream_options;
84
+ if (streamOptions !== undefined) body.stream_options = streamOptions;
85
+
86
+ if (params.metadata !== undefined) body.metadata = params.metadata;
87
+ if (params.temperature !== undefined) body.temperature = params.temperature;
88
+
89
+ const topP = params.topP ?? params.top_p;
90
+ if (topP !== undefined) body.top_p = topP;
91
+
92
+ const maxTokens = params.maxTokens ?? params.max_tokens;
93
+ if (maxTokens !== undefined) body.max_tokens = maxTokens;
94
+
95
+ if (params.stop !== undefined) body.stop = params.stop;
96
+
97
+ if (params.tools !== undefined) body.tools = params.tools;
98
+
99
+ const toolChoice = params.toolChoice ?? params.tool_choice;
100
+ if (toolChoice !== undefined) body.tool_choice = toolChoice;
101
+
102
+ const parallelToolCalls = params.parallelToolCalls ?? params.parallel_tool_calls;
103
+ if (parallelToolCalls !== undefined) body.parallel_tool_calls = parallelToolCalls;
104
+
105
+ const reasoningEffort = params.reasoningEffort ?? params.reasoning_effort;
106
+ if (reasoningEffort !== undefined) body.reasoning_effort = reasoningEffort;
107
+
108
+ return body;
109
+ }
110
+
111
+ /**
112
+ * Iterate a `data:`-only SSE stream (no `event:` names), yielding parsed
113
+ * JSON chunks and stopping (without yielding) on the literal `data: [DONE]`
114
+ * sentinel. Shared by both the agentic and passthrough streaming paths,
115
+ * which use the same wire-level framing but different payload shapes.
116
+ *
117
+ * @param {Response} response
118
+ * @returns {AsyncGenerator<any, void, unknown>}
119
+ */
120
+ async function* iterateDataOnlySSE(response) {
121
+ for await (const sseEvent of parseSSEStream(response)) {
122
+ if (!sseEvent.data) continue;
123
+ if (sseEvent.data === "[DONE]") return;
124
+ yield JSON.parse(sseEvent.data);
125
+ }
126
+ }
127
+
128
+ export class ChatCompletions {
129
+ /** @param {import("../http.js").HttpTransport} http */
130
+ constructor(http) {
131
+ this._http = http;
132
+ }
133
+
134
+ /**
135
+ * `POST /v1/chat/completions` for agentic tiers (`clark`, `clark_max`,
136
+ * `openrouter:*`) — non-streaming. Do NOT pass `tools`/`tool_choice` here;
137
+ * the server rejects them with `400 unsupported_parameter` for agentic
138
+ * tiers. Use `createPassthrough` for `model: "clark-code"` with tools.
139
+ *
140
+ * @param {CreateChatCompletionParams} params
141
+ * @returns {Promise<ChatCompletionObject>}
142
+ */
143
+ async create(params) {
144
+ const body = buildChatBody(params, false);
145
+ return this._http.requestJson("POST", "/v1/chat/completions", {
146
+ body,
147
+ signal: params.signal,
148
+ });
149
+ }
150
+
151
+ /**
152
+ * `POST /v1/chat/completions` with `stream: true` for agentic tiers.
153
+ * Returns an async generator over `chat.completion.chunk` objects; the
154
+ * stream ends (generator returns) when the server sends `data: [DONE]`.
155
+ * The final usage chunk only appears if `streamOptions.include_usage` (or
156
+ * `stream_options.include_usage`) was `true`.
157
+ *
158
+ * @param {CreateChatCompletionParams} params
159
+ * @returns {AsyncGenerator<ChatCompletionChunk, void, unknown>}
160
+ */
161
+ async *stream(params) {
162
+ const body = buildChatBody(params, true);
163
+ const response = await this._http.requestStream("POST", "/v1/chat/completions", {
164
+ body,
165
+ signal: params.signal,
166
+ });
167
+ yield* iterateDataOnlySSE(response);
168
+ }
169
+
170
+ /**
171
+ * `POST /v1/chat/completions` with `model: "clark-code"` (or
172
+ * `"clark-code:<option>"`) — the passthrough tier. `tools`/`tool_choice`
173
+ * and the full `messages` array (including prior `tool_calls`/`tool`
174
+ * messages) are forwarded verbatim to the upstream OpenAI-compatible
175
+ * provider. The response is the RAW upstream OpenAI-shaped object, not a
176
+ * Clark `ChatCompletionObject` — do not assume the `clark`/Clark usage
177
+ * shape here. `conversationId`/`previousResponseId`/`memoryScope` are
178
+ * meaningless for this tier; omit them.
179
+ *
180
+ * @param {CreatePassthroughChatCompletionParams} params
181
+ * @returns {Promise<Object>} Raw upstream OpenAI-compatible chat completion object.
182
+ */
183
+ async createPassthrough(params) {
184
+ const body = buildChatBody(params, false);
185
+ return this._http.requestJson("POST", "/v1/chat/completions", {
186
+ body,
187
+ signal: params.signal,
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Streaming counterpart of `createPassthrough`. Returns an async generator
193
+ * over the raw upstream provider's native `chat.completion.chunk`-shaped
194
+ * SSE payloads (including native `tool_calls` deltas) ending on
195
+ * `data: [DONE]`. The caller is responsible for running its own tool loop
196
+ * — Clark does not execute tools for this tier.
197
+ *
198
+ * @param {CreatePassthroughChatCompletionParams} params
199
+ * @returns {AsyncGenerator<Object, void, unknown>}
200
+ */
201
+ async *streamPassthrough(params) {
202
+ const body = buildChatBody(params, true);
203
+ const response = await this._http.requestStream("POST", "/v1/chat/completions", {
204
+ body,
205
+ signal: params.signal,
206
+ });
207
+ yield* iterateDataOnlySSE(response);
208
+ }
209
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @typedef {Object} MemoryListParams
3
+ * @property {string} [q] Search string.
4
+ * @property {string} [tags]
5
+ * @property {string} [conversationId] Scope to a conversation's memory instead of the caller's user-scoped memory.
6
+ * @property {AbortSignal} [signal]
7
+ *
8
+ * @typedef {Object} MemoryList
9
+ * @property {"list"} object
10
+ * @property {Array<Object>} data Memory record shape is defined by clark_memory_service; treated as opaque here.
11
+ */
12
+
13
+ export class Memories {
14
+ /** @param {import("../http.js").HttpTransport} http */
15
+ constructor(http) {
16
+ this._http = http;
17
+ }
18
+
19
+ /**
20
+ * `GET /v1/memories` — list durable memory records for the caller.
21
+ *
22
+ * @param {MemoryListParams} [params]
23
+ * @returns {Promise<MemoryList>}
24
+ */
25
+ async list(params = {}) {
26
+ const conversationId = params.conversationId ?? params.conversation_id;
27
+ return this._http.requestJson("GET", "/v1/memories", {
28
+ query: {
29
+ q: params.q,
30
+ tags: params.tags,
31
+ conversation_id: conversationId,
32
+ },
33
+ signal: params.signal,
34
+ });
35
+ }
36
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @typedef {Object} ClarkModelPricing
3
+ * @property {string} currency
4
+ * @property {string} unit
5
+ * @property {number} input
6
+ * @property {number} output
7
+ * @property {number} [cache_write]
8
+ * @property {number} [cache_read]
9
+ *
10
+ * @typedef {Object} ClarkModelCapabilities
11
+ * @property {string[]} public_input_modalities
12
+ * @property {string[]} model_input_modalities
13
+ * @property {string[]} output_modalities
14
+ * @property {string[]} features
15
+ * @property {boolean} public_file_upload
16
+ *
17
+ * @typedef {Object} ClarkModelDetail
18
+ * @property {string} tier_id
19
+ * @property {string} label
20
+ * @property {string} description
21
+ * @property {number} context_window_tokens
22
+ * @property {number} max_output_tokens
23
+ * @property {ClarkModelPricing} pricing
24
+ * @property {ClarkModelCapabilities} capabilities
25
+ * @property {ClarkModelEntry[]} [model_options]
26
+ *
27
+ * @typedef {Object} ClarkModelEntry
28
+ * @property {string} id
29
+ * @property {"model"} object
30
+ * @property {string} owned_by
31
+ * @property {ClarkModelDetail} clark
32
+ *
33
+ * @typedef {Object} ClarkModelList
34
+ * @property {"list"} object
35
+ * @property {ClarkModelEntry[]} data
36
+ */
37
+
38
+ export class Models {
39
+ /** @param {import("../http.js").HttpTransport} http */
40
+ constructor(http) {
41
+ this._http = http;
42
+ }
43
+
44
+ /**
45
+ * `GET /v1/models` — list model tiers/options with pricing + capabilities.
46
+ * Treat the returned tier/option ids as dynamic; never hardcode them.
47
+ *
48
+ * @param {Object} [options]
49
+ * @param {AbortSignal} [options.signal]
50
+ * @returns {Promise<ClarkModelList>}
51
+ */
52
+ async list(options = {}) {
53
+ return this._http.requestJson("GET", "/v1/models", { signal: options.signal });
54
+ }
55
+ }
@@ -0,0 +1,186 @@
1
+ import { parseSSEStream } from "../sse.js";
2
+
3
+ /**
4
+ * @typedef {"user"|"assistant"|string} ResponseInputRole
5
+ *
6
+ * @typedef {Object} ResponseInputTextPart
7
+ * @property {"text"|"input_text"} type
8
+ * @property {string} text
9
+ *
10
+ * @typedef {Object} ResponseInputItem
11
+ * @property {ResponseInputRole} [role]
12
+ * @property {string|ResponseInputTextPart[]} content
13
+ *
14
+ * @typedef {Object} CreateResponseParams
15
+ * @property {string} model Bare tier id (e.g. "clark") or "tier:option" (e.g. "openrouter:qwen35_flash").
16
+ * @property {string} [tierModelId] Explicit tier/model id; wins over `model` if both are present.
17
+ * @property {string|ResponseInputItem[]} input
18
+ * @property {string} [conversationId]
19
+ * @property {"user"|"conversation"} [memoryScope]
20
+ * @property {string} [previousResponseId]
21
+ * @property {boolean} [background]
22
+ * @property {Record<string, unknown>} [metadata]
23
+ * @property {AbortSignal} [signal]
24
+ *
25
+ * @typedef {Object} PublicUsageCost
26
+ * @property {string|null} amount
27
+ * @property {string} currency
28
+ * @property {"estimated"|"unavailable"|string} type
29
+ *
30
+ * @typedef {Object} PublicUsage
31
+ * @property {number} input_tokens
32
+ * @property {{cached_tokens: number}} input_tokens_details
33
+ * @property {number} output_tokens
34
+ * @property {{reasoning_tokens: number}} output_tokens_details
35
+ * @property {number} total_tokens
36
+ * @property {number} [artifact_bytes]
37
+ * @property {PublicUsageCost} cost
38
+ *
39
+ * @typedef {Object} PublicArtifact
40
+ * @property {string} id
41
+ * @property {string} name
42
+ * @property {string} kind
43
+ * @property {string} role
44
+ * @property {string} mime_type
45
+ * @property {number} size_bytes
46
+ * @property {string} [summary]
47
+ * @property {string} download_url
48
+ *
49
+ * @typedef {Object} PublicResponseMetadata
50
+ * @property {string} response_id
51
+ * @property {string} conversation_id
52
+ * @property {string} run_id
53
+ * @property {"in_progress"|"completed"|"failed"} status
54
+ * @property {PublicArtifact[]} artifacts
55
+ *
56
+ * @typedef {Object} ResponseObject
57
+ * @property {string} id
58
+ * @property {"response"} object
59
+ * @property {"in_progress"|"completed"|"failed"} status
60
+ * @property {number} created_at
61
+ * @property {string} model
62
+ * @property {string|null} previous_response_id
63
+ * @property {boolean} background
64
+ * @property {Array<Object>} output
65
+ * @property {PublicArtifact[]} artifacts
66
+ * @property {PublicUsage} [usage]
67
+ * @property {Record<string, unknown>} [metadata]
68
+ * @property {PublicResponseMetadata} clark
69
+ * @property {{type: string, message: string}|null} error
70
+ */
71
+
72
+ /**
73
+ * Build the JSON body for POST /v1/responses from either camelCase or
74
+ * wire-native snake_case fields (both accepted for ergonomics).
75
+ *
76
+ * @param {CreateResponseParams & Record<string, unknown>} params
77
+ * @param {boolean} stream
78
+ */
79
+ function buildResponsesBody(params, stream) {
80
+ const body = {
81
+ model: params.model,
82
+ input: params.input,
83
+ stream,
84
+ };
85
+ const tierModelId = params.tierModelId ?? params.tier_model_id;
86
+ if (tierModelId !== undefined) body.tier_model_id = tierModelId;
87
+
88
+ const conversationId = params.conversationId ?? params.conversation_id;
89
+ if (conversationId !== undefined) body.conversation_id = conversationId;
90
+
91
+ const memoryScope = params.memoryScope ?? params.memory_scope;
92
+ if (memoryScope !== undefined) body.memory_scope = memoryScope;
93
+
94
+ const previousResponseId = params.previousResponseId ?? params.previous_response_id;
95
+ if (previousResponseId !== undefined) body.previous_response_id = previousResponseId;
96
+
97
+ if (params.background !== undefined) body.background = params.background;
98
+ if (params.metadata !== undefined) body.metadata = params.metadata;
99
+
100
+ return body;
101
+ }
102
+
103
+ export class Responses {
104
+ /** @param {import("../http.js").HttpTransport} http */
105
+ constructor(http) {
106
+ this._http = http;
107
+ }
108
+
109
+ /**
110
+ * `POST /v1/responses` (non-streaming, or `background: true` to return
111
+ * immediately with an `in_progress` object for polling).
112
+ *
113
+ * @param {CreateResponseParams} params
114
+ * @returns {Promise<ResponseObject>}
115
+ */
116
+ async create(params) {
117
+ const body = buildResponsesBody(params, false);
118
+ return this._http.requestJson("POST", "/v1/responses", { body, signal: params.signal });
119
+ }
120
+
121
+ /**
122
+ * `POST /v1/responses` with `stream: true`. Returns an async generator over
123
+ * the named SSE events (`response.created`, `response.in_progress`,
124
+ * `response.output_text.delta`, `response.output_text.done`,
125
+ * `response.artifact.completed`, `response.usage.updated`,
126
+ * `response.completed` / `response.failed`).
127
+ *
128
+ * The full answer text arrives as a single `response.output_text.delta`
129
+ * event, not token-by-token — do not assume many small chunks.
130
+ *
131
+ * @param {CreateResponseParams} params
132
+ * @returns {AsyncGenerator<{type: string, sequence_number: number, [key: string]: unknown}, void, unknown>}
133
+ */
134
+ async *stream(params) {
135
+ const body = buildResponsesBody(params, true);
136
+ const response = await this._http.requestStream("POST", "/v1/responses", {
137
+ body,
138
+ signal: params.signal,
139
+ });
140
+ for await (const sseEvent of parseSSEStream(response)) {
141
+ if (!sseEvent.data) continue;
142
+ yield JSON.parse(sseEvent.data);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * `GET /v1/responses/{response_id}` — poll a response by its stable id.
148
+ *
149
+ * @param {string} responseId
150
+ * @param {Object} [options]
151
+ * @param {AbortSignal} [options.signal]
152
+ * @returns {Promise<ResponseObject>}
153
+ */
154
+ async get(responseId, options = {}) {
155
+ return this._http.requestJson("GET", `/v1/responses/${encodeURIComponent(responseId)}`, {
156
+ signal: options.signal,
157
+ });
158
+ }
159
+
160
+ /**
161
+ * `GET /v1/responses/{response_id}/events` — poll public progress events.
162
+ *
163
+ * @param {string} responseId
164
+ * @param {Object} [options]
165
+ * @param {number} [options.afterSeq] Default 0.
166
+ * @param {number} [options.limit] Default 200, max 500.
167
+ * @param {string[]|string} [options.types] Comma-separated allowlist filter.
168
+ * @param {AbortSignal} [options.signal]
169
+ * @returns {Promise<{object: "list", data: Array<Object>, next_after_seq: number}>}
170
+ */
171
+ async listEvents(responseId, options = {}) {
172
+ const types = Array.isArray(options.types) ? options.types.join(",") : options.types;
173
+ return this._http.requestJson(
174
+ "GET",
175
+ `/v1/responses/${encodeURIComponent(responseId)}/events`,
176
+ {
177
+ query: {
178
+ after_seq: options.afterSeq,
179
+ limit: options.limit,
180
+ types,
181
+ },
182
+ signal: options.signal,
183
+ }
184
+ );
185
+ }
186
+ }
package/src/sse.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Minimal Server-Sent Events (SSE) line-protocol parser, built directly on
3
+ * the Web Streams API (`Response.body` as a `ReadableStream<Uint8Array>`),
4
+ * with no external dependency. Works identically in Node 18+ (native
5
+ * `fetch`/`ReadableStream`) and modern browsers.
6
+ *
7
+ * @typedef {Object} SSEEvent
8
+ * @property {string|null} event SSE `event:` field, or null if the stream
9
+ * only sends bare `data:` lines (as `/v1/chat/completions` streaming does).
10
+ * @property {string} data The (possibly multi-line, newline-joined) `data:` payload.
11
+ * @property {string|null} id SSE `id:` field, if present.
12
+ */
13
+
14
+ /**
15
+ * Parse a fetch Response body as an SSE stream, yielding one `SSEEvent` per
16
+ * blank-line-delimited block.
17
+ *
18
+ * @param {Response} response A fetch Response whose `.body` is a ReadableStream.
19
+ * @returns {AsyncGenerator<SSEEvent, void, unknown>}
20
+ */
21
+ export async function* parseSSEStream(response) {
22
+ if (!response.body) {
23
+ throw new Error("Response has no readable body to stream from");
24
+ }
25
+
26
+ const decoder = new TextDecoder("utf-8");
27
+ const reader = response.body.getReader();
28
+ let buffer = "";
29
+
30
+ try {
31
+ while (true) {
32
+ const { done, value } = await reader.read();
33
+ if (done) break;
34
+ buffer += decoder.decode(value, { stream: true });
35
+
36
+ // SSE blocks are separated by a blank line. Support both \n\n and \r\n\r\n.
37
+ let boundary;
38
+ while ((boundary = findBlockBoundary(buffer)) !== -1) {
39
+ const rawBlock = buffer.slice(0, boundary.start);
40
+ buffer = buffer.slice(boundary.end);
41
+ const parsed = parseBlock(rawBlock);
42
+ if (parsed) yield parsed;
43
+ }
44
+ }
45
+
46
+ // Flush any trailing block without a final blank-line terminator.
47
+ buffer += decoder.decode();
48
+ const trimmed = buffer.trim();
49
+ if (trimmed.length > 0) {
50
+ const parsed = parseBlock(buffer);
51
+ if (parsed) yield parsed;
52
+ }
53
+ } finally {
54
+ reader.releaseLock();
55
+ }
56
+ }
57
+
58
+ /**
59
+ * @param {string} buffer
60
+ * @returns {{start: number, end: number}|-1}
61
+ */
62
+ function findBlockBoundary(buffer) {
63
+ const idxLfLf = buffer.indexOf("\n\n");
64
+ const idxCrLfCrLf = buffer.indexOf("\r\n\r\n");
65
+ if (idxCrLfCrLf !== -1 && (idxLfLf === -1 || idxCrLfCrLf < idxLfLf)) {
66
+ return { start: idxCrLfCrLf, end: idxCrLfCrLf + 4 };
67
+ }
68
+ if (idxLfLf !== -1) {
69
+ return { start: idxLfLf, end: idxLfLf + 2 };
70
+ }
71
+ return -1;
72
+ }
73
+
74
+ /**
75
+ * @param {string} block
76
+ * @returns {SSEEvent|null}
77
+ */
78
+ function parseBlock(block) {
79
+ const lines = block.split(/\r\n|\n/);
80
+ /** @type {string|null} */
81
+ let event = null;
82
+ /** @type {string|null} */
83
+ let id = null;
84
+ const dataLines = [];
85
+
86
+ for (const line of lines) {
87
+ if (line === "" || line.startsWith(":")) continue;
88
+ const colonIdx = line.indexOf(":");
89
+ const field = colonIdx === -1 ? line : line.slice(0, colonIdx);
90
+ let value = colonIdx === -1 ? "" : line.slice(colonIdx + 1);
91
+ if (value.startsWith(" ")) value = value.slice(1);
92
+
93
+ if (field === "event") event = value;
94
+ else if (field === "data") dataLines.push(value);
95
+ else if (field === "id") id = value;
96
+ // "retry" and unknown fields are ignored — not used by the Clark API.
97
+ }
98
+
99
+ if (dataLines.length === 0 && event === null) return null;
100
+ return { event, data: dataLines.join("\n"), id };
101
+ }