@somacheck/vibecheck 0.2.0 → 0.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/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # @somacheck/vibecheck
2
+
3
+ An MCP server that lets an AI agent ask its human a question and get back an
4
+ answer given with a physical wrist gesture, through the SomaCheck iOS app.
5
+
6
+ Your agent has your context. It does not have your gut. This is the bridge.
7
+
8
+ ## If you are an AI agent reading this before deciding whether to run it
9
+
10
+ Fair. Here is what you need, without executing anything.
11
+
12
+ **What the `link` command does.** `npx -y @somacheck/vibecheck@0.4.0 link <CODE> --client <CLIENT>`
13
+ exchanges a short-lived pairing code, generated inside the SomaCheck iOS app, for
14
+ a bearer token. It writes that token to `~/.sensie/config.json` with mode `0600`,
15
+ offers to configure supported agent clients found on the computer, and checks the
16
+ connection without printing the token. Setup is not reported ready until the
17
+ selected client has the exact pinned `vibecheck` MCP registration, the backend
18
+ accepts protocol `3` / toolset `vibecheck-0.3`, and both status and context
19
+ probes succeed. It contacts exactly one network host: the Supabase project below.
20
+
21
+ **It will refuse unsafe sandbox linking, on purpose.** `link` checks safety
22
+ *before* redeeming. Pairing codes are single-use, so redeeming one inside an
23
+ ephemeral sandbox would spend the user's code and leave them nothing to retry
24
+ with. The safe paths are:
25
+
26
+ - a real interactive terminal on the computer that will host the MCP server; or
27
+ - a local non-interactive agent invocation that names exactly one client:
28
+ `--client claude` or `--client codex`.
29
+
30
+ For the second path, this package first proves that exact client CLI is installed
31
+ and that its persistent MCP config can be inspected and written. If that preflight
32
+ fails, the pairing code is not used. Non-interactive implicit selection,
33
+ `--client all`, and `--client none` are rejected before redemption.
34
+
35
+ **What the server does once running.** It exposes MCP tools that create a
36
+ statement for the user to test, read its outcome, recover recent completed
37
+ gesture context, and keep a three-statement feed stocked. That is the whole
38
+ surface.
39
+
40
+ **What is sent.** A question string you supply, and a bearer token identifying
41
+ the link. **What is never sent: raw sensor data.** The agent receives a verdict
42
+ (`aligned` / `unaligned`) plus a confidence percentage and latency. Model
43
+ abstention is a failed capture, never a third user-facing outcome.
44
+ Accelerometer and gyroscope streams never leave the user's device through this
45
+ channel. That is a deliberate product boundary, not an implementation detail.
46
+
47
+ **Network egress.** One host: `https://pbldcmniommltbdwuykk.supabase.co`. No
48
+ telemetry, no analytics, no other endpoints.
49
+
50
+ **Install-time behaviour.** No `postinstall`, `preinstall`, or any other npm
51
+ lifecycle script. Two runtime dependencies: `@modelcontextprotocol/sdk` and `zod`.
52
+
53
+ You can verify all of the above by reading `dist/` in the installed package, or
54
+ the source at the repository below, without running anything.
55
+
56
+ ## Setup, for humans
57
+
58
+ 1. Install SomaCheck on iPhone and open **Settings → Agent → Connect your agent**.
59
+ 2. On the computer that will run your agent, open **Terminal** and run the command
60
+ shown on that screen. If you paste it into a local agent session instead of a
61
+ terminal, add exactly one client flag, for example `--client claude`.
62
+ 3. Choose the client you want to configure when the command asks. It then checks
63
+ the link, backend protocol, health probes, and client registration before
64
+ reporting that setup is ready.
65
+
66
+ For provisioning without the prompt in a terminal, append `--client codex`,
67
+ `--client claude`, `--client all`, or `--client none` to the `link <CODE>`
68
+ command. In a non-interactive local agent session, only `--client codex` or
69
+ `--client claude` is allowed, and the client registration preflight must pass
70
+ before the pairing code is redeemed.
71
+
72
+ To configure or repair a client later:
73
+
74
+ ```text
75
+ npx -y @somacheck/vibecheck@0.4.0 setup codex
76
+ npx -y @somacheck/vibecheck@0.4.0 setup claude
77
+ npx -y @somacheck/vibecheck@0.4.0 doctor
78
+ ```
79
+
80
+ Manual registration remains available:
81
+
82
+ ```json
83
+ {
84
+ "mcpServers": {
85
+ "vibecheck": {
86
+ "command": "npx",
87
+ "args": ["-y", "@somacheck/vibecheck@0.4.0", "serve", "--client", "codex"]
88
+ }
89
+ }
90
+ }
91
+ ```
92
+
93
+ For Claude Code: `claude mcp add --scope user vibecheck -- npx -y @somacheck/vibecheck@0.4.0 serve --client claude`
94
+
95
+ The link step writes only the bearer token in `~/.sensie/config.json`. The client
96
+ setup step asks Codex or Claude to add the pinned MCP command to that client's own
97
+ configuration; it never copies the bearer token there. The interactive command
98
+ completes both steps. Most clients only pick up a new server after restart.
99
+
100
+ If `doctor` reports another MCP server named `somacheck`, that is the legacy
101
+ hosted connector and is not part of this local 0.3 release. The setup command
102
+ will explain it and leave it alone. The canonical local MCP key is `vibecheck`.
103
+
104
+ ## Tools
105
+
106
+ | Tool | What it does |
107
+ |---|---|
108
+ | `get_vibecheck_context` | Read recent completed gestures for this exact agent link without retaining every `request_id`. |
109
+ | `get_vibecheck_status` | Read the live three-proposition cache and how many distinct insights it needs. |
110
+ | `post_vibecheck_statement` | Add one to three personalized statements without blocking; returns a stable `request_id` for each. |
111
+ | `get_vibecheck_result` | Read that exact proposition once by `request_id`; returns queued, pending, or its terminal result. |
112
+
113
+ ## How to use it well
114
+
115
+ Ask when you genuinely need a read you cannot infer — not to check in. A good
116
+ prompt is **a statement for the person to test**, not a question about facts:
117
+ "you are more worried about the launch than the roadmap suggests." Offer it as
118
+ something to test, never as a claim about them. If it is wrong, their body says
119
+ `unaligned`, and that is a useful answer rather than a failure.
120
+
121
+ The agent maintains three distinct insights. SomaCheck presents one at a time;
122
+ the other two remain cached. After a completed gesture, the next cached insight
123
+ is promoted and the open slot is marked for replenishment automatically. Cached insights
124
+ expire sooner than a presented check-in so stale context does not masquerade as
125
+ a timely observation.
126
+
127
+ For a prompt-free loop, run the agent on a background schedule: call
128
+ `get_vibecheck_context`, then `get_vibecheck_status`, and post exactly
129
+ `propositions_needed` distinct statements. MCP supplies the tools and durable
130
+ context, but it does not itself wake an idle model. Immediate event-driven runs
131
+ require a host webhook/agent trigger; scheduled runs provide the portable path
132
+ across local MCP clients.
133
+
134
+ ## Privacy
135
+
136
+ <https://joinsensie.com/privacy>
137
+
138
+ ## Support
139
+
140
+ Issues: see the repository. Contact: agents@joinsensie.com
package/dist/api.js CHANGED
@@ -1,12 +1,35 @@
1
- import { BackendTooOldError, StatementPendingError } from "./vibecheck.js";
2
- /**
3
- * Marker raised by somacheck_engine.agent_request_create when rule 1 refuses a
4
- * second statement. PostgREST maps the SQLSTATE (PT409) onto HTTP 409; the
5
- * marker is the fallback for any hop that flattens the status.
6
- */
1
+ import { StatementPendingError, } from "./vibecheck.js";
2
+ import { BACKEND_PROTOCOL_VERSION, PACKAGE_VERSION, TOOLSET_VERSION } from "./constants.js";
7
3
  const STATEMENT_PENDING_MARKER = "SC_VC_STATEMENT_PENDING";
8
- const STATUSES = new Set(["pending", "answered", "expired", "cancelled"]);
9
- const VERDICTS = new Set(["aligned", "unaligned", "inconclusive"]);
4
+ const UPGRADE_REQUIRED_MARKER = "SC_VC_UPGRADE_REQUIRED";
5
+ const STATUSES = new Set(["queued", "pending", "answered", "expired", "cancelled"]);
6
+ const VERDICTS = new Set(["aligned", "unaligned"]);
7
+ const HANDSHAKE_READINESS_STATUSES = new Set([
8
+ "ready",
9
+ "upgrade_required",
10
+ "setup_incomplete",
11
+ "health_check_failed",
12
+ ]);
13
+ export class SomaCheckHttpError extends Error {
14
+ status;
15
+ constructor(status) {
16
+ super(`SomaCheck request failed (HTTP ${status}).`);
17
+ this.status = status;
18
+ this.name = "SomaCheckHttpError";
19
+ }
20
+ }
21
+ export class SomaCheckCompatibilityError extends Error {
22
+ requiredPackageVersion;
23
+ requiredProtocolVersion;
24
+ requiredToolsetVersion;
25
+ constructor(requiredPackageVersion, requiredProtocolVersion, requiredToolsetVersion) {
26
+ super("SomaCheck agent protocol is incompatible with this package.");
27
+ this.requiredPackageVersion = requiredPackageVersion;
28
+ this.requiredProtocolVersion = requiredProtocolVersion;
29
+ this.requiredToolsetVersion = requiredToolsetVersion;
30
+ this.name = "SomaCheckCompatibilityError";
31
+ }
32
+ }
10
33
  function firstRow(value) {
11
34
  const row = Array.isArray(value) ? value[0] : value;
12
35
  if (row === null || typeof row !== "object" || Array.isArray(row)) {
@@ -14,10 +37,9 @@ function firstRow(value) {
14
37
  }
15
38
  return row;
16
39
  }
17
- function requiredString(row, key) {
18
- const value = row[key];
19
- if (typeof value !== "string" || value.length === 0) {
20
- throw new Error("SomaCheck returned an incomplete response.");
40
+ function allRows(value) {
41
+ if (!Array.isArray(value) || value.some((row) => row === null || typeof row !== "object" || Array.isArray(row))) {
42
+ throw new Error("SomaCheck returned an unexpected response.");
21
43
  }
22
44
  return value;
23
45
  }
@@ -27,91 +49,177 @@ function asObject(value) {
27
49
  }
28
50
  return value;
29
51
  }
30
- function asBoolean(row, key) {
52
+ function requiredString(row, key) {
31
53
  const value = row[key];
32
- if (typeof value !== "boolean") {
54
+ if (typeof value !== "string" || value.length === 0) {
33
55
  throw new Error("SomaCheck returned an incomplete response.");
34
56
  }
35
57
  return value;
36
58
  }
37
- function asInteger(row, key) {
59
+ function requiredTimestamp(row, key) {
60
+ const value = requiredString(row, key);
61
+ if (!Number.isFinite(Date.parse(value))) {
62
+ throw new Error("SomaCheck returned an invalid timestamp.");
63
+ }
64
+ return value;
65
+ }
66
+ function nullableString(row, key) {
38
67
  const value = row[key];
39
- if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
68
+ if (value === null || value === undefined)
69
+ return null;
70
+ if (typeof value !== "string")
40
71
  throw new Error("SomaCheck returned an incomplete response.");
41
- }
42
72
  return value;
43
73
  }
44
- function asNumber(row, key) {
74
+ function requiredBoolean(row, key) {
45
75
  const value = row[key];
46
- if (typeof value !== "number" || !Number.isFinite(value)) {
76
+ if (typeof value !== "boolean")
47
77
  throw new Error("SomaCheck returned an incomplete response.");
48
- }
49
78
  return value;
50
79
  }
51
- function nullableString(row, key) {
80
+ function requiredNumber(row, key) {
52
81
  const value = row[key];
53
- if (value === null || value === undefined) {
54
- return null;
55
- }
56
- if (typeof value !== "string") {
82
+ if (typeof value !== "number" || !Number.isFinite(value)) {
57
83
  throw new Error("SomaCheck returned an incomplete response.");
58
84
  }
59
85
  return value;
60
86
  }
87
+ function requiredInteger(row, key) {
88
+ const value = requiredNumber(row, key);
89
+ if (!Number.isInteger(value))
90
+ throw new Error("SomaCheck returned an incomplete response.");
91
+ return value;
92
+ }
61
93
  function parseCadence(value) {
62
- // A backend that has not had the cadence migration applied returns the
63
- // first-run keys and nothing else. Say so precisely: reporting it as a link
64
- // failure sends the user off re-pairing a link that is perfectly fine.
65
- if (value === undefined || value === null) {
66
- throw new BackendTooOldError();
67
- }
68
94
  const row = asObject(value);
69
- const blocking = row.blocking_request;
70
- let blockingRequest = null;
71
- if (blocking !== null && blocking !== undefined) {
72
- const parsed = asObject(blocking);
73
- blockingRequest = {
74
- id: requiredString(parsed, "id"),
75
- created_at: requiredString(parsed, "created_at"),
76
- age_seconds: asNumber(parsed, "age_seconds"),
77
- };
78
- }
79
95
  return {
80
- as_of: requiredString(row, "as_of"),
81
- may_create_now: asBoolean(row, "may_create_now"),
82
96
  reason: requiredString(row, "reason"),
83
- blocking_request: blockingRequest,
84
- last_answered_at: nullableString(row, "last_answered_at"),
85
97
  create_due_at: nullableString(row, "create_due_at"),
86
- create_overdue: asBoolean(row, "create_overdue"),
87
- stale_after_hours: asNumber(row, "stale_after_hours"),
88
- followup_within_hours: asNumber(row, "followup_within_hours"),
89
- guidance: requiredString(row, "guidance"),
98
+ create_overdue: requiredBoolean(row, "create_overdue"),
99
+ may_create_now: requiredBoolean(row, "may_create_now"),
100
+ last_answered_at: nullableString(row, "last_answered_at"),
101
+ blocking_request_id: nullableString(row, "blocking_request_id"),
102
+ stale_after_seconds: requiredNumber(row, "stale_after_seconds"),
103
+ followup_within_seconds: requiredNumber(row, "followup_within_seconds"),
90
104
  };
91
105
  }
92
- function parseStatusResponse(row) {
93
- const firstRunIntro = asObject(row.first_run_intro);
94
- const copyGuidance = asObject(row.copy_guidance);
95
- const policy = copyGuidance.policy;
96
- const framing = copyGuidance.framing;
97
- const fallback = copyGuidance.fallback;
98
- if (typeof policy !== "string" || typeof framing !== "string" || typeof fallback !== "string") {
99
- throw new Error("SomaCheck returned an incomplete response.");
100
- }
106
+ function parseStatus(row) {
107
+ const intro = asObject(row.first_run_intro);
108
+ const copy = asObject(row.copy_guidance);
101
109
  return {
102
110
  linked: true,
103
- no_prior_requests: asBoolean(row, "no_prior_requests"),
104
- prior_request_count: asInteger(row, "prior_request_count"),
105
- has_pending_request: asBoolean(row, "has_pending_request"),
111
+ no_prior_requests: requiredBoolean(row, "no_prior_requests"),
112
+ prior_request_count: requiredInteger(row, "prior_request_count"),
113
+ has_pending_request: requiredBoolean(row, "has_pending_request"),
114
+ pending_proposition_count: requiredInteger(row, "pending_proposition_count"),
115
+ queued_proposition_count: requiredInteger(row, "queued_proposition_count"),
116
+ propositions_needed: requiredInteger(row, "propositions_needed"),
117
+ replenishment_requested_at: nullableString(row, "replenishment_requested_at"),
106
118
  first_run_intro: {
107
- eligible: asBoolean(firstRunIntro, "eligible"),
108
- should_offer_now: asBoolean(firstRunIntro, "should_offer_now"),
119
+ eligible: requiredBoolean(intro, "eligible"),
120
+ should_offer_now: requiredBoolean(intro, "should_offer_now"),
109
121
  },
110
122
  cadence: parseCadence(row.cadence),
111
123
  recommended_action: requiredString(row, "recommended_action"),
112
- copy_guidance: { policy, framing, fallback },
124
+ copy_guidance: {
125
+ policy: requiredString(copy, "policy"),
126
+ framing: requiredString(copy, "framing"),
127
+ fallback: requiredString(copy, "fallback"),
128
+ },
129
+ };
130
+ }
131
+ /** Shared strict decoders used by both local stdio and hosted OAuth MCP. */
132
+ export function decodeVibecheckStatus(value) {
133
+ return parseStatus(asObject(value));
134
+ }
135
+ export function decodeCreatedRequests(value) {
136
+ return allRows(value).map((row) => {
137
+ const presentationState = requiredString(row, "presentation_state");
138
+ if (presentationState !== "presented" && presentationState !== "queued") {
139
+ throw new Error("SomaCheck returned an incomplete response.");
140
+ }
141
+ return {
142
+ request_id: requiredString(row, "request_id"),
143
+ presentation_state: presentationState,
144
+ stale_at: nullableString(row, "expires_at"),
145
+ };
146
+ });
147
+ }
148
+ export function decodePollResponse(value) {
149
+ const row = asObject(value);
150
+ const status = row.status;
151
+ if (typeof status !== "string" || !STATUSES.has(status)) {
152
+ throw new Error("SomaCheck returned an invalid request status.");
153
+ }
154
+ const verdict = row.verdict;
155
+ const confidence = row.confidence;
156
+ const latency = row.latency_s;
157
+ if (verdict !== null && (typeof verdict !== "string" || !VERDICTS.has(verdict))) {
158
+ throw new Error("SomaCheck returned an invalid verdict.");
159
+ }
160
+ if (confidence !== null && (typeof confidence !== "number" || !Number.isFinite(confidence))) {
161
+ throw new Error("SomaCheck returned an invalid confidence.");
162
+ }
163
+ if (latency !== null && (typeof latency !== "number" || !Number.isFinite(latency) || latency < 0)) {
164
+ throw new Error("SomaCheck returned an invalid latency.");
165
+ }
166
+ if (status === "answered") {
167
+ if (verdict === null || confidence === null || confidence < 0 || confidence > 1 || latency === null) {
168
+ throw new Error("SomaCheck returned an incomplete answered result.");
169
+ }
170
+ }
171
+ else if (verdict !== null || confidence !== null || latency !== null) {
172
+ throw new Error("SomaCheck returned an inconsistent pending result.");
173
+ }
174
+ return {
175
+ status: status,
176
+ verdict: verdict,
177
+ confidence: confidence,
178
+ latency_s: latency,
113
179
  };
114
180
  }
181
+ export function decodeVibecheckContext(value) {
182
+ return allRows(value).map((row) => {
183
+ const verdict = requiredString(row, "verdict");
184
+ const confidence = requiredNumber(row, "confidence");
185
+ if (!VERDICTS.has(verdict)) {
186
+ throw new Error("SomaCheck returned an invalid verdict.");
187
+ }
188
+ if (confidence < 0 || confidence > 1) {
189
+ throw new Error("SomaCheck returned an invalid confidence.");
190
+ }
191
+ return {
192
+ request_id: requiredString(row, "request_id"),
193
+ statement: requiredString(row, "statement"),
194
+ verdict: verdict,
195
+ confidence,
196
+ answered_at: requiredTimestamp(row, "answered_at"),
197
+ };
198
+ });
199
+ }
200
+ function parseHandshake(row) {
201
+ const readinessStatus = requiredString(row, "readiness_status");
202
+ if (!HANDSHAKE_READINESS_STATUSES.has(readinessStatus)) {
203
+ throw new Error("SomaCheck returned an invalid readiness status.");
204
+ }
205
+ const result = {
206
+ readiness_status: readinessStatus,
207
+ upgrade_required: requiredBoolean(row, "upgrade_required"),
208
+ required_package_version: requiredString(row, "required_package_version"),
209
+ required_protocol_version: requiredInteger(row, "required_protocol_version"),
210
+ required_toolset_version: requiredString(row, "required_toolset_version"),
211
+ client_id: nullableString(row, "client_id"),
212
+ link_id: nullableString(row, "link_id"),
213
+ last_successful_health_check_at: nullableString(row, "last_successful_health_check_at"),
214
+ };
215
+ if (result.upgrade_required || result.readiness_status === "upgrade_required") {
216
+ throw new SomaCheckCompatibilityError(result.required_package_version, result.required_protocol_version, result.required_toolset_version);
217
+ }
218
+ if (result.readiness_status === "ready" && result.last_successful_health_check_at === null) {
219
+ throw new Error("SomaCheck returned an incomplete readiness response.");
220
+ }
221
+ return result;
222
+ }
115
223
  export class SupabaseAgentApi {
116
224
  #endpoint;
117
225
  #apiKey;
@@ -125,45 +233,39 @@ export class SupabaseAgentApi {
125
233
  const row = await this.#rpc("agent_link_redeem", { code });
126
234
  return requiredString(row, "token");
127
235
  }
128
- async createRequest(token, question) {
129
- const row = await this.#rpc("agent_request_create", { token, question });
130
- return {
131
- request_id: requiredString(row, "request_id"),
132
- // Absent until the cadence migration is applied; the caller treats a null
133
- // stale_at as "unknown", never as "never goes stale".
134
- stale_at: nullableString(row, "expires_at"),
135
- };
236
+ async createRequests(token, statements) {
237
+ return decodeCreatedRequests(await this.#rpcJson("agent_proposition_batch_create", { token, statements }));
136
238
  }
137
239
  async pollRequest(token, requestId) {
138
- const row = await this.#rpc("agent_request_poll", { token, request_id: requestId });
139
- const status = row.status;
140
- if (typeof status !== "string" || !STATUSES.has(status)) {
141
- throw new Error("SomaCheck returned an invalid request status.");
142
- }
143
- const verdict = row.verdict;
144
- const confidence = row.confidence;
145
- const latency = row.latency_s;
146
- if (verdict !== null && (typeof verdict !== "string" || !VERDICTS.has(verdict))) {
147
- throw new Error("SomaCheck returned an invalid verdict.");
148
- }
149
- if (confidence !== null && typeof confidence !== "number") {
150
- throw new Error("SomaCheck returned an invalid confidence.");
151
- }
152
- if (latency !== null && typeof latency !== "number") {
153
- throw new Error("SomaCheck returned an invalid latency.");
154
- }
155
- return {
156
- status: status,
157
- verdict: verdict,
158
- confidence: confidence,
159
- latency_s: latency,
160
- };
240
+ return decodePollResponse(await this.#rpc("agent_proposition_result", { token, proposition_id: requestId }));
161
241
  }
162
242
  async statusRequest(token) {
163
- const row = await this.#rpc("agent_request_status", { token });
164
- return parseStatusResponse(row);
243
+ return decodeVibecheckStatus(await this.#rpc("agent_proposition_cache_status", { token }));
244
+ }
245
+ async contextRequest(token, limit = 20) {
246
+ return decodeVibecheckContext(await this.#rpcJson("agent_proposition_context", { token, p_limit: limit }));
247
+ }
248
+ async clientHandshake(token, input) {
249
+ const row = await this.#rpc("agent_client_handshake", {
250
+ token,
251
+ p_client_key: input.client_key,
252
+ p_client_label: input.client_label,
253
+ p_package_version: PACKAGE_VERSION,
254
+ p_protocol_version: BACKEND_PROTOCOL_VERSION,
255
+ p_toolset_version: TOOLSET_VERSION,
256
+ p_registration_exact: input.registration_exact,
257
+ p_status_probe_ok: input.status_probe_ok,
258
+ p_context_probe_ok: input.context_probe_ok,
259
+ });
260
+ return parseHandshake(row);
261
+ }
262
+ async #rpcRows(name, body) {
263
+ return allRows(await this.#rpcJson(name, body));
165
264
  }
166
265
  async #rpc(name, body) {
266
+ return firstRow(await this.#rpcJson(name, body));
267
+ }
268
+ async #rpcJson(name, body) {
167
269
  const response = await this.#fetch(`${this.#endpoint}/rest/v1/rpc/${name}`, {
168
270
  method: "POST",
169
271
  headers: {
@@ -176,19 +278,21 @@ export class SupabaseAgentApi {
176
278
  body: JSON.stringify(body),
177
279
  });
178
280
  if (!response.ok) {
179
- const body = await response.text().catch(() => "");
180
- if (response.status === 409 || body.includes(STATEMENT_PENDING_MARKER)) {
281
+ const responseBody = await response.text().catch(() => "");
282
+ if (response.status === 426 || responseBody.includes(UPGRADE_REQUIRED_MARKER)) {
283
+ throw new SomaCheckCompatibilityError(null, null, null);
284
+ }
285
+ if (response.status === 409 || responseBody.includes(STATEMENT_PENDING_MARKER)) {
181
286
  throw new StatementPendingError();
182
287
  }
183
- throw new Error(`SomaCheck request failed (HTTP ${response.status}).`);
288
+ throw new SomaCheckHttpError(response.status);
184
289
  }
185
290
  try {
186
- return firstRow(await response.json());
291
+ return await response.json();
187
292
  }
188
293
  catch (error) {
189
- if (error instanceof Error && error.message.startsWith("SomaCheck returned")) {
294
+ if (error instanceof Error && error.message.startsWith("SomaCheck returned"))
190
295
  throw error;
191
- }
192
296
  throw new Error("SomaCheck returned an unreadable response.");
193
297
  }
194
298
  }