@somacheck/vibecheck 0.5.0 → 0.6.1

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
@@ -9,13 +9,13 @@ Your agent has your context. It does not have your gut. This is the bridge.
9
9
 
10
10
  Fair. Here is what you need, without executing anything.
11
11
 
12
- **What the `link` command does.** `npx -y @somacheck/vibecheck@0.5.0 link <CODE> --client <CLIENT>`
12
+ **What the `link` command does.** `npx -y @somacheck/vibecheck@0.6.1 link <CODE> --client <CLIENT>`
13
13
  exchanges a short-lived pairing code, generated inside the SomaCheck iOS app, for
14
14
  a bearer token. It writes that token to `~/.sensie/config.json` with mode `0600`,
15
15
  offers to configure supported agent clients found on the computer, and checks the
16
16
  connection without printing the token. Setup is not reported ready until the
17
17
  selected client has the exact pinned `vibecheck` MCP registration, the backend
18
- accepts protocol `4` / toolset `vibecheck-0.5`, and both status and context
18
+ accepts protocol `5` / toolset `vibecheck-0.6`, and both status and context
19
19
  probes succeed. It contacts exactly one network host: the Supabase project below.
20
20
 
21
21
  **It will refuse unsafe sandbox linking, on purpose.** `link` checks safety
@@ -35,7 +35,9 @@ fails, the pairing code is not used. Non-interactive implicit selection,
35
35
  **What the server does once running.** It exposes MCP tools that share bounded,
36
36
  user-authorized context summaries, create a statement for the user to test,
37
37
  read its outcome, recover recent completed gesture context, and keep a
38
- three-statement feed stocked.
38
+ three-statement feed stocked. Version 0.6 adds `request_vibecheck` for a
39
+ person-approved, immediate phone check-in. That path is deliberately separate
40
+ from the reflection feed.
39
41
 
40
42
  **What is sent.** A statement string, bounded derived context observations the
41
43
  agent explicitly shares, and a bearer token identifying the link. Tool
@@ -76,9 +78,9 @@ before the pairing code is redeemed.
76
78
  To configure or repair a client later:
77
79
 
78
80
  ```text
79
- npx -y @somacheck/vibecheck@0.5.0 setup codex
80
- npx -y @somacheck/vibecheck@0.5.0 setup claude
81
- npx -y @somacheck/vibecheck@0.5.0 doctor
81
+ npx -y @somacheck/vibecheck@0.6.1 setup codex
82
+ npx -y @somacheck/vibecheck@0.6.1 setup claude
83
+ npx -y @somacheck/vibecheck@0.6.1 doctor
82
84
  ```
83
85
 
84
86
  Manual registration remains available:
@@ -88,13 +90,13 @@ Manual registration remains available:
88
90
  "mcpServers": {
89
91
  "vibecheck": {
90
92
  "command": "npx",
91
- "args": ["-y", "@somacheck/vibecheck@0.5.0", "serve", "--client", "codex"]
93
+ "args": ["-y", "@somacheck/vibecheck@0.6.1", "serve", "--client", "codex"]
92
94
  }
93
95
  }
94
96
  }
95
97
  ```
96
98
 
97
- For Claude Code: `claude mcp add --scope user vibecheck -- npx -y @somacheck/vibecheck@0.5.0 serve --client claude`
99
+ For Claude Code: `claude mcp add --scope user vibecheck -- npx -y @somacheck/vibecheck@0.6.1 serve --client claude`
98
100
 
99
101
  The link step writes only the bearer token in `~/.sensie/config.json`. The client
100
102
  setup step asks Codex or Claude to add the pinned MCP command to that client's own
@@ -114,6 +116,7 @@ will explain it and leave it alone. The canonical local MCP key is `vibecheck`.
114
116
  | `share_somacheck_context` | Share 1–20 bounded, derived, user-authorized observations; raw conversations and secrets are rejected. |
115
117
  | `post_vibecheck_statement` | Add one to three personalized statements without blocking; returns a stable `request_id` for each. |
116
118
  | `get_vibecheck_result` | Read that exact proposition once by `request_id`; returns queued, pending, or its terminal result. |
119
+ | `request_vibecheck` | With per-ask consent, send one immediate phone vibecheck, wait boundedly for its exact answer, and retain an opaque `live:<uuid>` fallback handle. |
117
120
 
118
121
  ## How to use it well
119
122
 
@@ -123,6 +126,39 @@ prompt is **a statement for the person to test**, not a question about facts:
123
126
  something to test, never as a claim about them. If it is wrong, their body says
124
127
  `unaligned`, and that is a useful answer rather than a failure.
125
128
 
129
+ Setup and linking are not permission for future asks. If the person's current
130
+ message explicitly asks you to use SomaCheck and includes the exact statement,
131
+ that is consent for this one vibecheck; do not ask them to confirm twice.
132
+ Otherwise, first show the exact statement and invite the person plainly:
133
+
134
+ > Want a quick vibecheck before I continue? I'll send this statement to your
135
+ > phone; you answer with your wrist gesture, and I'll use the result as a
136
+ > signal—not a verdict.
137
+
138
+ Call `request_vibecheck` with `human_confirmed: true` and a new UUID in
139
+ `idempotency_key` only after one of those per-ask consent paths. Reuse that UUID
140
+ only when retrying the same statement. The tool keeps the one created request
141
+ open for a bounded wait and returns `completed` when the exact answer arrives.
142
+ On timeout or a result-read failure it returns the same pending `live:<uuid>`
143
+ handle; pass that handle to `get_vibecheck_result` once later. Caller
144
+ cancellation stops result reads and preserves the backend request and
145
+ idempotency key, but the cancelled MCP transport may suppress that pending
146
+ response. Do not continuously poll or re-ask the same decision to obtain a
147
+ preferred result.
148
+
149
+ The 45-second whole-call deadline also covers the one create request. If that
150
+ network request is aborted after an ambiguous commit, retry the exact same
151
+ statement with the same `idempotency_key`; never create a replacement ask.
152
+
153
+ After a completed result, explain what the signal changes in your next action.
154
+ Never treat it as authorization or say that the person's body made the
155
+ decision. A typed response from the person always overrides the signal.
156
+
157
+ An immediate vibecheck is not proposition caching. It sends one person-approved statement
158
+ for the current choice, expires, and remains bound to its originating client.
159
+ The reflection feed remains asynchronous and keeps its existing three-item
160
+ cache behavior.
161
+
126
162
  The agent maintains three distinct insights. SomaCheck presents one at a time;
127
163
  the other two remain cached. After a completed gesture, the next cached insight
128
164
  is promoted and the open slot is marked for replenishment automatically. Cached insights
package/dist/api.js CHANGED
@@ -2,6 +2,10 @@ import { StatementPendingError, } from "./vibecheck.js";
2
2
  import { BACKEND_PROTOCOL_VERSION, PACKAGE_VERSION, TOOLSET_VERSION } from "./constants.js";
3
3
  const STATEMENT_PENDING_MARKER = "SC_VC_STATEMENT_PENDING";
4
4
  const UPGRADE_REQUIRED_MARKER = "SC_VC_UPGRADE_REQUIRED";
5
+ const LIVE_ASK_CONFLICT_MARKER = "SC_VC_VIBECHECK_IDEMPOTENCY_CONFLICT";
6
+ const LIVE_ASK_PENDING_MARKER = "SC_VC_VIBECHECK_PENDING";
7
+ const LIVE_ASK_CLIENT_REQUIRED_MARKER = "SC_VC_VIBECHECK_CLIENT_REQUIRED";
8
+ const LIVE_ASK_CLIENT_NOT_READY_MARKER = "SC_VC_VIBECHECK_CLIENT_NOT_READY";
5
9
  const STATUSES = new Set(["queued", "pending", "answered", "expired", "cancelled"]);
6
10
  const VERDICTS = new Set(["aligned", "unaligned"]);
7
11
  const HANDSHAKE_READINESS_STATUSES = new Set([
@@ -10,6 +14,9 @@ const HANDSHAKE_READINESS_STATUSES = new Set([
10
14
  "setup_incomplete",
11
15
  "health_check_failed",
12
16
  ]);
17
+ const PUSH_STATUSES = new Set([
18
+ "queued", "processing", "sent", "failed", "skipped",
19
+ ]);
13
20
  const CONTEXT_CATEGORY = {
14
21
  goal: "goals_projects", project: "goals_projects",
15
22
  decision: "decisions_commitments", commitment: "decisions_commitments",
@@ -40,6 +47,24 @@ export class SomaCheckCompatibilityError extends Error {
40
47
  this.name = "SomaCheckCompatibilityError";
41
48
  }
42
49
  }
50
+ export class SomaCheckLiveAskConflictError extends Error {
51
+ constructor() {
52
+ super("That Live Ask retry key is already bound to a different statement.");
53
+ this.name = "SomaCheckLiveAskConflictError";
54
+ }
55
+ }
56
+ export class SomaCheckLiveAskPendingError extends Error {
57
+ constructor() {
58
+ super("A vibecheck is already waiting for this connection.");
59
+ this.name = "SomaCheckLiveAskPendingError";
60
+ }
61
+ }
62
+ export class SomaCheckLiveAskClientError extends Error {
63
+ constructor() {
64
+ super("Live Ask requires an exact ready client connection.");
65
+ this.name = "SomaCheckLiveAskClientError";
66
+ }
67
+ }
43
68
  function firstRow(value) {
44
69
  const row = Array.isArray(value) ? value[0] : value;
45
70
  if (row === null || typeof row !== "object" || Array.isArray(row)) {
@@ -155,6 +180,39 @@ export function decodeCreatedRequests(value) {
155
180
  };
156
181
  });
157
182
  }
183
+ /** Strict decoder for agent_vibecheck_create. PostgREST wraps the
184
+ * set-returning function in an array. The backend intentionally uses its
185
+ * canonical `answered` lifecycle name; the provider-neutral adapter below
186
+ * maps that to the tool's clearer `completed` state. */
187
+ export function decodeLiveVibecheckCreation(value) {
188
+ const row = firstRow(value);
189
+ const status = requiredString(row, "status");
190
+ if (status !== "pending" && status !== "answered" && status !== "expired" && status !== "cancelled") {
191
+ throw new Error("SomaCheck returned an invalid vibecheck status.");
192
+ }
193
+ if (requiredString(row, "request_kind") !== "live_ask") {
194
+ throw new Error("SomaCheck returned an invalid vibecheck request kind.");
195
+ }
196
+ const pushStatus = nullableString(row, "push_status");
197
+ if (pushStatus !== null && !PUSH_STATUSES.has(pushStatus)) {
198
+ throw new Error("SomaCheck returned an invalid vibecheck delivery state.");
199
+ }
200
+ return {
201
+ request_id: requiredString(row, "request_id"),
202
+ status,
203
+ expires_at: requiredTimestamp(row, "expires_at"),
204
+ idempotent_replay: requiredBoolean(row, "idempotent_replay"),
205
+ push_status: pushStatus,
206
+ cooldown_until: nullableTimestamp(row, "cooldown_until"),
207
+ };
208
+ }
209
+ function nullableTimestamp(row, key) {
210
+ const value = nullableString(row, key);
211
+ if (value !== null && !Number.isFinite(Date.parse(value))) {
212
+ throw new Error("SomaCheck returned an invalid timestamp.");
213
+ }
214
+ return value;
215
+ }
158
216
  export function decodePollResponse(value) {
159
217
  const row = asObject(value);
160
218
  const status = row.status;
@@ -188,6 +246,21 @@ export function decodePollResponse(value) {
188
246
  latency_s: latency,
189
247
  };
190
248
  }
249
+ export function decodeLiveVibecheckResult(value, expectedRequestId) {
250
+ const row = firstRow(value);
251
+ if (requiredString(row, "request_id") !== expectedRequestId) {
252
+ throw new Error("SomaCheck returned a mismatched vibecheck result.");
253
+ }
254
+ if (requiredString(row, "request_kind") !== "live_ask") {
255
+ throw new Error("SomaCheck returned an invalid vibecheck request kind.");
256
+ }
257
+ const result = decodePollResponse(row);
258
+ if (result.status === "queued") {
259
+ throw new Error("SomaCheck returned an invalid vibecheck status.");
260
+ }
261
+ requiredTimestamp(row, "expires_at");
262
+ return result;
263
+ }
191
264
  export function decodeVibecheckContext(value) {
192
265
  return allRows(value).map((row) => {
193
266
  const verdict = requiredString(row, "verdict");
@@ -261,12 +334,45 @@ export class SupabaseAgentApi {
261
334
  const row = await this.#rpc("agent_link_redeem", { code });
262
335
  return requiredString(row, "token");
263
336
  }
337
+ async requestVibecheck(token, identity, statement, idempotencyKey, signal) {
338
+ if (identity.kind !== "local")
339
+ throw new SomaCheckLiveAskClientError();
340
+ const clientKey = identity.client_key;
341
+ const created = decodeLiveVibecheckCreation(await this.#rpcJson("agent_vibecheck_create", {
342
+ token,
343
+ statement,
344
+ idempotency_key: idempotencyKey,
345
+ client_key: clientKey,
346
+ }, signal));
347
+ const lifecycle = created.status === "answered"
348
+ ? await this.liveVibecheckResult(token, identity, created.request_id, signal)
349
+ : { status: created.status, verdict: null, confidence: null, latency_s: null };
350
+ return {
351
+ request_id: `live:${created.request_id}`,
352
+ state: lifecycle.status === "answered" ? "completed" : lifecycle.status,
353
+ verdict: lifecycle.verdict,
354
+ confidence: lifecycle.confidence,
355
+ expires_at: created.expires_at,
356
+ idempotent_replay: created.idempotent_replay,
357
+ delivery_state: created.push_status,
358
+ cooldown_until: created.cooldown_until,
359
+ };
360
+ }
264
361
  async createRequests(token, statements) {
265
362
  return decodeCreatedRequests(await this.#rpcJson("agent_proposition_batch_create", { token, statements }));
266
363
  }
267
364
  async pollRequest(token, requestId) {
268
365
  return decodePollResponse(await this.#rpc("agent_proposition_result", { token, proposition_id: requestId }));
269
366
  }
367
+ async liveVibecheckResult(token, identity, requestId, signal) {
368
+ if (identity.kind !== "local")
369
+ throw new SomaCheckLiveAskClientError();
370
+ return decodeLiveVibecheckResult(await this.#rpcJson("agent_vibecheck_result", {
371
+ token,
372
+ live_ask_id: requestId,
373
+ client_key: identity.client_key,
374
+ }, signal), requestId);
375
+ }
270
376
  async statusRequest(token) {
271
377
  return decodeVibecheckStatus(await this.#rpc("agent_proposition_cache_status", { token }));
272
378
  }
@@ -312,7 +418,7 @@ export class SupabaseAgentApi {
312
418
  async #rpc(name, body) {
313
419
  return firstRow(await this.#rpcJson(name, body));
314
420
  }
315
- async #rpcJson(name, body) {
421
+ async #rpcJson(name, body, signal) {
316
422
  const response = await this.#fetch(`${this.#endpoint}/rest/v1/rpc/${name}`, {
317
423
  method: "POST",
318
424
  headers: {
@@ -323,12 +429,23 @@ export class SupabaseAgentApi {
323
429
  "accept-profile": "somacheck_engine",
324
430
  },
325
431
  body: JSON.stringify(body),
432
+ ...(signal === undefined ? {} : { signal }),
326
433
  });
327
434
  if (!response.ok) {
328
435
  const responseBody = await response.text().catch(() => "");
329
436
  if (response.status === 426 || responseBody.includes(UPGRADE_REQUIRED_MARKER)) {
330
437
  throw new SomaCheckCompatibilityError(null, null, null);
331
438
  }
439
+ if (response.status === 409 && responseBody.includes(LIVE_ASK_CONFLICT_MARKER)) {
440
+ throw new SomaCheckLiveAskConflictError();
441
+ }
442
+ if (response.status === 409 && responseBody.includes(LIVE_ASK_PENDING_MARKER)) {
443
+ throw new SomaCheckLiveAskPendingError();
444
+ }
445
+ if (responseBody.includes(LIVE_ASK_CLIENT_REQUIRED_MARKER)
446
+ || responseBody.includes(LIVE_ASK_CLIENT_NOT_READY_MARKER)) {
447
+ throw new SomaCheckLiveAskClientError();
448
+ }
332
449
  if (response.status === 409 || responseBody.includes(STATEMENT_PENDING_MARKER)) {
333
450
  throw new StatementPendingError();
334
451
  }
package/dist/cli.js CHANGED
@@ -182,6 +182,9 @@ async function startServer(runtimeClient) {
182
182
  const server = createVibecheckServer({
183
183
  api,
184
184
  loadToken: async () => (await readConfig(homedir(), runtimeClient ?? undefined)).token,
185
+ identity: runtimeClient === null
186
+ ? { kind: "legacy" }
187
+ : { kind: "local", client_key: runtimeClient },
185
188
  });
186
189
  await server.connect(new StdioServerTransport());
187
190
  if (runtimeClient !== null) {
@@ -91,6 +91,8 @@ function registrationMatches(client, stdout) {
91
91
  return false;
92
92
  }
93
93
  }
94
+ if (/^\s*(?:Enabled|Status):\s*(?:false|disabled|off)\s*$/im.test(stdout))
95
+ return false;
94
96
  const command = /^\s*Command:\s*(\S+)\s*$/im.exec(stdout)?.[1];
95
97
  const rawArgs = /^\s*Args:\s*(.*?)\s*$/im.exec(stdout)?.[1] ?? "";
96
98
  const parsedArgs = rawArgs.split(/\s+/).filter(Boolean);
@@ -117,15 +119,35 @@ function isManagedSomaCheckRegistration(client, stdout) {
117
119
  }
118
120
  }
119
121
  else {
122
+ if (/^\s*(?:Enabled|Status):\s*(?:false|disabled|off)\s*$/im.test(stdout))
123
+ return false;
120
124
  command = /^\s*Command:\s*(\S+)\s*$/im.exec(stdout)?.[1];
121
125
  args = (/^\s*Args:\s*(.*?)\s*$/im.exec(stdout)?.[1] ?? "").split(/\s+/).filter(Boolean);
122
126
  }
123
127
  if (command !== "npx")
124
128
  return false;
125
129
  const packageArgs = args.filter((arg) => arg.startsWith("@somacheck/vibecheck@"));
126
- return packageArgs.length === 1
127
- && /^@somacheck\/vibecheck@0\.[0-3]\.\d+$/.test(packageArgs[0])
128
- && args.every((arg) => arg === "-y" || arg === "--yes" || arg === packageArgs[0]);
130
+ if (packageArgs.length !== 1)
131
+ return false;
132
+ const packageArg = packageArgs[0];
133
+ const legacyWithoutClient = /^@somacheck\/vibecheck@0\.[0-3]\.\d+$/.test(packageArg)
134
+ && args.every((arg) => arg === "-y" || arg === "--yes" || arg === packageArg);
135
+ if (legacyWithoutClient)
136
+ return true;
137
+ // 0.5 introduced the exact local runtime identity required by Live Ask.
138
+ // It is safe to replace only the canonical command generated by 0.5 or the
139
+ // exact 0.6.0 release being patched. Custom wrappers, extra arguments,
140
+ // disabled entries, later versions, and registrations naming the other
141
+ // client remain user-owned.
142
+ const previousExactClient = (/^@somacheck\/vibecheck@0\.5\.\d+$/.test(packageArg)
143
+ || packageArg === "@somacheck/vibecheck@0.6.0")
144
+ && args.length === 5
145
+ && args[0] === "-y"
146
+ && args[1] === packageArg
147
+ && args[2] === "serve"
148
+ && args[3] === "--client"
149
+ && args[4] === client;
150
+ return previousExactClient;
129
151
  }
130
152
  export async function isClientRegistered(client, runner) {
131
153
  return (await clientRegistrationState(client, runner)) === "current";
package/dist/constants.js CHANGED
@@ -2,17 +2,18 @@
2
2
  export const SUPABASE_URL = "https://pbldcmniommltbdwuykk.supabase.co";
3
3
  export const SUPABASE_PUBLISHABLE_KEY = "sb_publishable_af-lUNI2FqEcb-oGy-4uxQ_cnm6kY85";
4
4
  export const PACKAGE_NAME = "@somacheck/vibecheck";
5
- export const PACKAGE_VERSION = "0.5.0";
5
+ export const PACKAGE_VERSION = "0.6.1";
6
6
  export const PACKAGE_SPEC = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
7
7
  export const MCP_SERVER_NAME = "vibecheck";
8
8
  export const LEGACY_HOSTED_MCP_SERVER_NAME = "somacheck";
9
- export const BACKEND_PROTOCOL_VERSION = 4;
10
- export const TOOLSET_VERSION = "vibecheck-0.5";
9
+ export const BACKEND_PROTOCOL_VERSION = 5;
10
+ export const TOOLSET_VERSION = "vibecheck-0.6";
11
11
  export const TOOL_NAMES = [
12
12
  "get_vibecheck_context",
13
13
  "get_vibecheck_status",
14
14
  "share_somacheck_context",
15
15
  "post_vibecheck_statement",
16
16
  "get_vibecheck_result",
17
+ "request_vibecheck",
17
18
  ];
18
19
  //# sourceMappingURL=constants.js.map
package/dist/server.js CHANGED
@@ -1,8 +1,28 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { StatementPendingError } from "./vibecheck.js";
4
- import { SomaCheckCompatibilityError, SomaCheckHttpError } from "./api.js";
3
+ import { StatementPendingError, } from "./vibecheck.js";
4
+ import { SomaCheckCompatibilityError, SomaCheckHttpError, SomaCheckLiveAskClientError, SomaCheckLiveAskConflictError, SomaCheckLiveAskPendingError, } from "./api.js";
5
5
  import { PACKAGE_NAME, PACKAGE_SPEC, PACKAGE_VERSION } from "./constants.js";
6
+ const LIVE_ASK_WAIT_BUDGET_MS = 45_000;
7
+ const LIVE_ASK_POLL_DELAYS_MS = [
8
+ 1_000, 1_000, 2_000, 2_000, 3_000, 3_000,
9
+ 4_000, 4_000, 5_000, 5_000, 5_000, 5_000,
10
+ ];
11
+ function abortAwareSleep(milliseconds, signal) {
12
+ if (signal.aborted || milliseconds <= 0)
13
+ return Promise.resolve();
14
+ return new Promise((resolve) => {
15
+ let timer;
16
+ const finish = () => {
17
+ if (timer !== undefined)
18
+ clearTimeout(timer);
19
+ signal.removeEventListener("abort", finish);
20
+ resolve();
21
+ };
22
+ timer = setTimeout(finish, milliseconds);
23
+ signal.addEventListener("abort", finish, { once: true });
24
+ });
25
+ }
6
26
  const cadenceSchema = z.object({
7
27
  reason: z.string(),
8
28
  create_due_at: z.string().nullable(),
@@ -48,6 +68,28 @@ const contextItemSchema = z.object({
48
68
  answered_at: z.string().datetime({ offset: true }),
49
69
  });
50
70
  const contextSchema = { checkins: z.array(contextItemSchema) };
71
+ const liveAskErrorCodeSchema = z.enum([
72
+ "link_revoked",
73
+ "upgrade_required",
74
+ "setup_required",
75
+ "request_conflict",
76
+ "vibecheck_pending",
77
+ "rate_limited",
78
+ "live_ask_unavailable",
79
+ "backend_unavailable",
80
+ "connection_failed",
81
+ ]);
82
+ const liveAskSchema = {
83
+ state: z.enum(["pending", "completed", "expired", "cancelled", "error"]),
84
+ request_id: z.string().nullable(),
85
+ verdict: z.enum(["aligned", "unaligned"]).nullable(),
86
+ confidence: z.number().min(0).max(1).nullable(),
87
+ expires_at: z.string().datetime({ offset: true }).nullable(),
88
+ idempotent_replay: z.boolean().nullable(),
89
+ delivery_state: z.enum(["queued", "processing", "sent", "failed", "skipped"]).nullable(),
90
+ cooldown_until: z.string().datetime({ offset: true }).nullable(),
91
+ error_code: liveAskErrorCodeSchema.nullable(),
92
+ };
51
93
  const agentContextTypeSchema = z.enum([
52
94
  "goal", "project", "decision", "commitment", "tension", "question",
53
95
  "blindSpot", "relationship", "situation", "change", "deadline",
@@ -69,6 +111,15 @@ const SERVER_INSTRUCTIONS = [
69
111
  "SomaCheck lets you offer the person a statement to test through a quick phone check-in.",
70
112
  "Call get_vibecheck_context and get_vibecheck_status at the start of a session or background run, and call status again after a completed check-in.",
71
113
  "Treat gesture outcomes as contextual signals, never fixed facts or blanket authorization.",
114
+ "Use request_vibecheck when a current choice would benefit from the person's own felt response; never use it to evaluate someone else.",
115
+ "A setup or link is not blanket consent for future vibechecks.",
116
+ "If the person's current message explicitly tells you to request a SomaCheck vibecheck and includes the exact statement, that message is consent for that one ask; set human_confirmed to true and do not ask them to confirm twice.",
117
+ "Otherwise, first show the exact statement and ask: Want a quick vibecheck before I continue? I'll send this statement to your phone; you answer with your wrist gesture, and I'll use the result as a signal—not a verdict. Call only after the person accepts.",
118
+ "Use at most one vibecheck per decision. Never re-ask to obtain a preferred answer.",
119
+ "An immediate vibecheck is separate from the asynchronous reflection feed. Do not use it to replenish cached propositions.",
120
+ "Use a fresh idempotency_key for each new vibecheck, and reuse that exact key only when retrying the same statement.",
121
+ "request_vibecheck waits boundedly for the exact answer. If it returns completed, continue in the same turn and explain what changes. If it returns pending, retain that exact handle and read it once later; never create a replacement ask.",
122
+ "After a completed result, say what the signal changes in your next action. Never say 'your body decided'; a typed human response always overrides the signal.",
72
123
  "When you have meaningful user-authorized context, call share_somacheck_context with concise derived observations before creating propositions.",
73
124
  "Never share raw conversation text, photos, credentials, private identifiers, or diagnostic claims.",
74
125
  "Maintain three distinct propositions drawn from your own context about this person.",
@@ -189,9 +240,9 @@ export function createVibecheckServer(dependencies) {
189
240
  });
190
241
  server.registerTool("get_vibecheck_result", {
191
242
  title: "Get Vibecheck Result",
192
- description: "Read one proposition by request_id. This is a single non-blocking read: queued is cached, and pending is presented but not answered.",
243
+ description: "Read one exact proposition or immediate vibecheck by request_id. This is a single non-blocking read: queued is cached, and pending is not answered. Immediate vibecheck handles begin with live: and remain bound to the originating client.",
193
244
  inputSchema: {
194
- request_id: z.string().min(1).describe("The request_id returned by post_vibecheck_statement."),
245
+ request_id: z.string().min(1).describe("The opaque request_id returned by post_vibecheck_statement or request_vibecheck."),
195
246
  },
196
247
  outputSchema: resultSchema,
197
248
  annotations: {
@@ -203,10 +254,19 @@ export function createVibecheckServer(dependencies) {
203
254
  }, async ({ request_id }) => {
204
255
  try {
205
256
  const token = await dependencies.loadToken();
206
- const result = await dependencies.api.pollRequest(token, request_id);
257
+ const liveRequestId = parseLiveVibecheckHandle(request_id);
258
+ const identity = dependencies.identity ?? { kind: "legacy" };
259
+ if (liveRequestId !== null && identity.kind === "legacy") {
260
+ return failure("Could not read that vibecheck: Live Ask needs the exact ready client connection that created it. Run setup and restart the client.");
261
+ }
262
+ const result = liveRequestId === null
263
+ ? await dependencies.api.pollRequest(token, request_id)
264
+ : await dependencies.api.liveVibecheckResult(token, identity, liveRequestId);
207
265
  const structuredContent = { request_id, ...result };
208
266
  const text = result.status === "answered"
209
- ? `Answered: ${Math.round((result.confidence ?? 0) * 100)}% ${result.verdict}.`
267
+ ? liveRequestId === null
268
+ ? `Answered: ${Math.round((result.confidence ?? 0) * 100)}% ${result.verdict}.`
269
+ : `Vibecheck completed: ${Math.round((result.confidence ?? 0) * 100)}% ${result.verdict}. Treat this as a signal, say what it changes in your next action, and defer to any typed response from the person.`
210
270
  : result.status === "queued"
211
271
  ? "Queued. The proposition is cached until the person advances their feed."
212
272
  : result.status === "pending"
@@ -218,8 +278,141 @@ export function createVibecheckServer(dependencies) {
218
278
  return failureMessage("read that SomaCheck result", error);
219
279
  }
220
280
  });
281
+ server.registerTool("request_vibecheck", {
282
+ title: "Request a Vibecheck",
283
+ description: "Send one plain-language statement to the person's phone for a vibecheck answered with a wrist gesture. A current user command that explicitly requests SomaCheck and includes the exact statement counts as confirmation for that one ask; do not ask twice. Otherwise, first show the exact statement and call only after the person accepts. Setup or linking is not blanket consent. Use this only when their own felt response would help with the current choice, not for routine feed content or to judge another person. The call waits boundedly for the exact result and returns completed when available. A timeout returns the same stable pending request_id for one later read. Caller cancellation stops result reads without creating another request; the cancelled transport may suppress the pending response.",
284
+ inputSchema: {
285
+ statement: z.string().trim().min(1).max(1000)
286
+ .describe("One plain-language statement for the person to test; offer a signal, not a fact, diagnosis, or instruction."),
287
+ idempotency_key: z.string().uuid()
288
+ .describe("A new UUID for this logical ask. Reuse the same UUID only to retry the exact same statement; retries will not create another phone request."),
289
+ human_confirmed: z.literal(true)
290
+ .describe("Confirms the person accepted this exact vibecheck, either by explicitly requesting SomaCheck with this statement in their current message or by accepting after the agent showed it."),
291
+ },
292
+ outputSchema: liveAskSchema,
293
+ annotations: {
294
+ readOnlyHint: false,
295
+ destructiveHint: false,
296
+ idempotentHint: true,
297
+ openWorldHint: false,
298
+ },
299
+ }, async ({ statement, idempotency_key }, extra) => {
300
+ const now = dependencies.liveAskWait?.now ?? (() => performance.now());
301
+ const budgetMs = dependencies.liveAskWait?.budgetMs ?? LIVE_ASK_WAIT_BUDGET_MS;
302
+ const deadline = now() + budgetMs;
303
+ const callAbort = new AbortController();
304
+ const forwardCallerAbort = () => callAbort.abort(extra.signal.reason);
305
+ if (extra.signal.aborted) {
306
+ forwardCallerAbort();
307
+ }
308
+ else {
309
+ extra.signal.addEventListener("abort", forwardCallerAbort, { once: true });
310
+ }
311
+ const callDeadline = setTimeout(() => callAbort.abort(new Error("Live Ask tool deadline reached.")), budgetMs);
312
+ try {
313
+ const identity = dependencies.identity ?? { kind: "legacy" };
314
+ if (identity.kind === "legacy") {
315
+ return liveAskFailure(new SomaCheckLiveAskClientError());
316
+ }
317
+ const token = await dependencies.loadToken();
318
+ const created = await dependencies.api.requestVibecheck(token, identity, statement.trim(), idempotency_key, callAbort.signal);
319
+ const result = await waitForLiveVibecheck(created, token, identity, dependencies, callAbort.signal, deadline, (attempt) => {
320
+ const progressToken = extra._meta?.progressToken;
321
+ if (progressToken === undefined)
322
+ return;
323
+ void extra.sendNotification({
324
+ method: "notifications/progress",
325
+ params: {
326
+ progressToken,
327
+ progress: attempt,
328
+ total: LIVE_ASK_POLL_DELAYS_MS.length,
329
+ message: "Waiting for the person's vibecheck signal.",
330
+ },
331
+ }).catch(() => {
332
+ // Progress is optional. A host that does not accept it must not
333
+ // prevent the exact result from completing this tool call.
334
+ });
335
+ });
336
+ const structuredContent = { ...result, error_code: null };
337
+ const text = result.state === "completed"
338
+ ? `Vibecheck completed: ${Math.round((result.confidence ?? 0) * 100)}% ${result.verdict}. Treat this as a signal, say what it changes in your next action, and defer to any typed response from the person.`
339
+ : result.state === "pending"
340
+ ? result.delivery_state === "failed" || result.delivery_state === "skipped"
341
+ ? `Vibecheck ${result.request_id} was stored, but phone delivery is ${result.delivery_state}. It expires at ${result.expires_at}. Do not repost it or change the idempotency_key; check the phone setup before a later decision.`
342
+ : `Vibecheck ${result.request_id} is still pending after a bounded wait. Keep this exact handle and check it once later with get_vibecheck_result. It expires at ${result.expires_at}; do not poll continuously or re-ask for this decision.`
343
+ : `This vibecheck is ${result.state}. Do not create another ask for the same decision.`;
344
+ return { content: [{ type: "text", text }], structuredContent };
345
+ }
346
+ catch (error) {
347
+ return liveAskFailure(error);
348
+ }
349
+ finally {
350
+ clearTimeout(callDeadline);
351
+ extra.signal.removeEventListener("abort", forwardCallerAbort);
352
+ }
353
+ });
221
354
  return server;
222
355
  }
356
+ async function waitForLiveVibecheck(created, token, identity, dependencies, signal, deadline, progress) {
357
+ if (created.state !== "pending"
358
+ || created.delivery_state === "failed"
359
+ || created.delivery_state === "skipped"
360
+ || signal.aborted) {
361
+ return created;
362
+ }
363
+ const requestId = parseLiveVibecheckHandle(created.request_id);
364
+ if (requestId === null)
365
+ return created;
366
+ const now = dependencies.liveAskWait?.now ?? (() => performance.now());
367
+ const sleep = dependencies.liveAskWait?.sleep ?? abortAwareSleep;
368
+ for (const [index, delay] of LIVE_ASK_POLL_DELAYS_MS.entries()) {
369
+ const remaining = deadline - now();
370
+ if (signal.aborted || remaining <= 0)
371
+ return created;
372
+ await sleep(Math.min(delay, remaining), signal);
373
+ if (signal.aborted || now() >= deadline)
374
+ return created;
375
+ progress(index + 1);
376
+ if (signal.aborted)
377
+ return created;
378
+ let lifecycle;
379
+ const resultAbort = new AbortController();
380
+ const forwardCallerAbort = () => resultAbort.abort(signal.reason);
381
+ signal.addEventListener("abort", forwardCallerAbort, { once: true });
382
+ const resultTimeout = setTimeout(() => resultAbort.abort(new Error("Live Ask result deadline reached.")), Math.max(0, deadline - now()));
383
+ try {
384
+ lifecycle = await dependencies.api.liveVibecheckResult(token, identity, requestId, resultAbort.signal);
385
+ }
386
+ catch {
387
+ // Creation already succeeded. Keep the exact handle so a transient,
388
+ // cancelled, revoked, or deadline-aborted read cannot prompt a duplicate.
389
+ return created;
390
+ }
391
+ finally {
392
+ clearTimeout(resultTimeout);
393
+ signal.removeEventListener("abort", forwardCallerAbort);
394
+ }
395
+ if (signal.aborted)
396
+ return created;
397
+ if (lifecycle.status === "answered") {
398
+ return {
399
+ ...created,
400
+ state: "completed",
401
+ verdict: lifecycle.verdict,
402
+ confidence: lifecycle.confidence,
403
+ };
404
+ }
405
+ if (lifecycle.status === "expired" || lifecycle.status === "cancelled") {
406
+ return {
407
+ ...created,
408
+ state: lifecycle.status,
409
+ verdict: null,
410
+ confidence: null,
411
+ };
412
+ }
413
+ }
414
+ return created;
415
+ }
223
416
  function summarise(status) {
224
417
  const cadence = status.cadence;
225
418
  if (status.first_run_intro.should_offer_now) {
@@ -237,6 +430,16 @@ function formatDuration(seconds) {
237
430
  const hours = seconds / 3600;
238
431
  return Number.isInteger(hours) ? `${hours}-hour` : `${Math.round(seconds / 60)}-minute`;
239
432
  }
433
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
434
+ function parseLiveVibecheckHandle(handle) {
435
+ if (!handle.startsWith("live:"))
436
+ return null;
437
+ const requestId = handle.slice("live:".length);
438
+ if (!UUID_PATTERN.test(requestId)) {
439
+ throw new Error("The Live Ask request_id is malformed.");
440
+ }
441
+ return requestId;
442
+ }
240
443
  function failure(text) {
241
444
  return {
242
445
  isError: true,
@@ -246,6 +449,62 @@ function failure(text) {
246
449
  function failureMessage(operation, error) {
247
450
  return failure(operationalFailureText(operation, error));
248
451
  }
452
+ function liveAskFailure(error) {
453
+ const doctor = `Run: npx -y ${PACKAGE_SPEC} doctor`;
454
+ let errorCode;
455
+ let text;
456
+ if (error instanceof SomaCheckCompatibilityError) {
457
+ errorCode = "upgrade_required";
458
+ text = `Could not request a vibecheck: this package needs an upgrade before immediate vibechecks can be used. ${doctor}`;
459
+ }
460
+ else if (error instanceof SomaCheckLiveAskClientError) {
461
+ errorCode = "setup_required";
462
+ text = `Could not request a vibecheck: immediate vibechecks need an exact ready agent connection. Run the pinned serve command through setup, then restart the client. ${doctor}`;
463
+ }
464
+ else if (error instanceof SomaCheckLiveAskConflictError) {
465
+ errorCode = "request_conflict";
466
+ text = "Could not request a vibecheck: that retry key already belongs to a different statement. Use the original statement, or use a new UUID for a genuinely new ask.";
467
+ }
468
+ else if (error instanceof SomaCheckLiveAskPendingError) {
469
+ errorCode = "vibecheck_pending";
470
+ text = "Could not request a vibecheck: one is already waiting for this connection. Keep the existing request; do not re-ask or change the retry key.";
471
+ }
472
+ else if (error instanceof SomaCheckHttpError && (error.status === 401 || error.status === 403)) {
473
+ errorCode = "link_revoked";
474
+ text = `Could not request a vibecheck: this SomaCheck link was rejected or revoked. Reconnect in the app. ${doctor}`;
475
+ }
476
+ else if (error instanceof SomaCheckHttpError && error.status === 404) {
477
+ errorCode = "live_ask_unavailable";
478
+ text = `Could not request a vibecheck: immediate vibechecks are not available on this SomaCheck backend yet. ${doctor}`;
479
+ }
480
+ else if (error instanceof SomaCheckHttpError && error.status === 429) {
481
+ errorCode = "rate_limited";
482
+ text = "Could not request a vibecheck: too many vibechecks were requested recently. Wait before making a new ask; do not change the retry key for the same ask.";
483
+ }
484
+ else if (error instanceof SomaCheckHttpError && error.status >= 500) {
485
+ errorCode = "backend_unavailable";
486
+ text = `Could not request a vibecheck: the SomaCheck backend is temporarily unavailable. Retry later with the same idempotency_key. ${doctor}`;
487
+ }
488
+ else {
489
+ errorCode = "connection_failed";
490
+ text = `Could not request a vibecheck: the local link or network check failed. Retry later with the same idempotency_key. ${doctor}`;
491
+ }
492
+ return {
493
+ isError: true,
494
+ content: [{ type: "text", text }],
495
+ structuredContent: {
496
+ state: "error",
497
+ request_id: null,
498
+ verdict: null,
499
+ confidence: null,
500
+ expires_at: null,
501
+ idempotent_replay: null,
502
+ delivery_state: null,
503
+ cooldown_until: null,
504
+ error_code: errorCode,
505
+ },
506
+ };
507
+ }
249
508
  function operationalFailureText(operation, error) {
250
509
  const doctor = `Run: npx -y ${PACKAGE_SPEC} doctor`;
251
510
  if (error instanceof SomaCheckCompatibilityError) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@somacheck/vibecheck",
3
- "version": "0.5.0",
4
- "description": "Ask your embodied sense for an answer through SomaCheck.",
3
+ "version": "0.6.1",
4
+ "description": "Send a consented vibecheck to SomaCheck and use the result as a signal.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/sensie-app/Somacheck.git",