@vellumai/assistant 0.4.34 → 0.4.35

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.4.34",
3
+ "version": "0.4.35",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -481,8 +481,8 @@ describe("assistant ID boundary", () => {
481
481
  const repoRoot = getRepoRoot();
482
482
 
483
483
  // Scan all Drizzle schema files for assistantId column definitions.
484
- // The Drizzle ORM pattern is `assistantId: text(` for defining a text
485
- // column named assistantId.
484
+ // Match `assistantId:` followed by any Drizzle column builder (text(,
485
+ // integer(, blob(, real(, etc.) — not just text(.
486
486
  const schemaGlobs = [
487
487
  "assistant/src/memory/schema/*.ts",
488
488
  "assistant/src/memory/schema/**/*.ts",
@@ -492,7 +492,7 @@ describe("assistant ID boundary", () => {
492
492
  try {
493
493
  grepOutput = execFileSync(
494
494
  "git",
495
- ["grep", "-nE", "assistantId\\s*:\\s*text\\(", "--", ...schemaGlobs],
495
+ ["grep", "-nE", "assistantId\\s*:", "--", ...schemaGlobs],
496
496
  { encoding: "utf-8", cwd: repoRoot },
497
497
  ).trim();
498
498
  } catch (err) {
@@ -546,6 +546,9 @@ describe("assistant ID boundary", () => {
546
546
  // Scan store files for exported function signatures that include
547
547
  // assistantId as a parameter. This covers memory stores, contact stores,
548
548
  // notification stores, credential/token stores, and call stores.
549
+ //
550
+ // We read each file and extract full parameter lists (which may span
551
+ // multiple lines) from exported functions to catch multiline signatures.
549
552
  const storeGlobs = [
550
553
  "assistant/src/memory/*.ts",
551
554
  "assistant/src/contacts/*.ts",
@@ -556,52 +559,68 @@ describe("assistant ID boundary", () => {
556
559
  "assistant/src/calls/call-store.ts",
557
560
  ];
558
561
 
559
- // Match exported function declarations/expressions with assistantId in
560
- // their parameter lists. Patterns:
561
- // export function foo(assistantId
562
- // export function foo(bar, assistantId
563
- // export async function foo(assistantId
564
- // export const foo = (assistantId
565
- // export const foo = async (assistantId
566
- // We use a broad pattern that catches assistantId appearing after an
567
- // opening paren in an export context.
568
- const pattern =
569
- "export\\s+(async\\s+)?function\\s+\\w+\\s*\\([^)]*assistantId|export\\s+const\\s+\\w+\\s*=\\s*(async\\s+)?\\([^)]*assistantId";
570
-
571
- let grepOutput = "";
572
- try {
573
- grepOutput = execFileSync(
574
- "git",
575
- ["grep", "-nE", pattern, "--", ...storeGlobs],
576
- { encoding: "utf-8", cwd: repoRoot },
577
- ).trim();
578
- } catch (err) {
579
- // Exit code 1 means no matches — happy path
580
- if ((err as { status?: number }).status === 1) {
581
- return;
562
+ // Find matching files using git ls-files with each glob
563
+ const matchedFiles: string[] = [];
564
+ for (const glob of storeGlobs) {
565
+ try {
566
+ const output = execFileSync("git", ["ls-files", "--", glob], {
567
+ encoding: "utf-8",
568
+ cwd: repoRoot,
569
+ }).trim();
570
+ if (output) {
571
+ matchedFiles.push(...output.split("\n").filter((f) => f.length > 0));
572
+ }
573
+ } catch {
574
+ // Ignore errors — glob may not match anything
582
575
  }
583
- throw err;
584
576
  }
585
577
 
586
- const allLines = grepOutput.split("\n").filter((l) => l.length > 0);
587
- const violations = allLines.filter((line) => {
588
- const filePath = line.split(":")[0];
589
- if (isTestFile(filePath)) return false;
590
- if (isMigrationFile(filePath)) return false;
578
+ const violations: string[] = [];
591
579
 
592
- // Allow comments
593
- const parts = line.split(":");
594
- const content = parts.slice(2).join(":").trim();
595
- if (
596
- content.startsWith("//") ||
597
- content.startsWith("*") ||
598
- content.startsWith("/*")
580
+ // Regex to find the start of an exported function declaration or
581
+ // arrow-function expression. We capture everything from `export` up to
582
+ // and including the opening parenthesis of the parameter list.
583
+ const exportFnStartRegex =
584
+ /export\s+(?:async\s+)?function\s+\w+\s*\(|export\s+const\s+\w+\s*=\s*(?:async\s+)?\(/g;
585
+
586
+ for (const relPath of matchedFiles) {
587
+ if (isTestFile(relPath) || isMigrationFile(relPath)) continue;
588
+
589
+ const content = readFileSync(join(repoRoot, relPath), "utf-8");
590
+
591
+ exportFnStartRegex.lastIndex = 0;
592
+ for (
593
+ let match = exportFnStartRegex.exec(content);
594
+ match;
595
+ match = exportFnStartRegex.exec(content)
599
596
  ) {
600
- return false;
601
- }
597
+ // Find the matching closing paren to extract the full parameter list,
598
+ // which may span multiple lines.
599
+ const parenStart = match.index + match[0].length - 1; // index of '('
600
+ let depth = 1;
601
+ let paramEnd = parenStart + 1;
602
+ for (let i = parenStart + 1; i < content.length && depth > 0; i++) {
603
+ if (content[i] === "(") depth++;
604
+ if (content[i] === ")") depth--;
605
+ if (depth === 0) {
606
+ paramEnd = i;
607
+ break;
608
+ }
609
+ }
602
610
 
603
- return true;
604
- });
611
+ const paramList = content.slice(parenStart + 1, paramEnd);
612
+
613
+ // Check if the parameter list contains assistantId as a word boundary
614
+ if (/\bassistantId\b/.test(paramList)) {
615
+ // Determine the line number of the export keyword for reporting
616
+ const lineNum = content.slice(0, match.index).split("\n").length;
617
+ const firstLine = content
618
+ .slice(match.index, match.index + match[0].length)
619
+ .trim();
620
+ violations.push(`${relPath}:${lineNum}: ${firstLine}...`);
621
+ }
622
+ }
623
+ }
605
624
 
606
625
  if (violations.length > 0) {
607
626
  const message = [
@@ -1,20 +1,20 @@
1
1
  ---
2
2
  name: "Google OAuth Setup"
3
- description: "Set up Google Cloud OAuth credentials for Gmail and Calendar using browser automation"
3
+ description: "Set up Google Cloud OAuth credentials for Gmail and Calendar"
4
4
  user-invocable: true
5
5
  credential-setup-for: "gmail"
6
- includes: ["browser", "public-ingress"]
7
- metadata: {"vellum": {"emoji": "\ud83d\udd11"}}
6
+ includes: ["public-ingress"]
7
+ metadata: { "vellum": { "emoji": "\ud83d\udd11" } }
8
8
  ---
9
9
 
10
10
  You are helping your user set up Google Cloud OAuth credentials so Gmail and Google Calendar integrations can connect.
11
11
 
12
12
  ## Client Check
13
13
 
14
- Determine whether the user has browser automation available (macOS desktop app) or is on a non-interactive channel (Telegram, SMS, etc.).
14
+ Determine which setup path to use based on the user's client:
15
15
 
16
- - **macOS desktop app**: Follow the **Automated Setup** path below.
17
- - **Telegram or other channel** (no browser automation): Follow the **Manual Setup for Channels** path below.
16
+ - **macOS desktop app**: Follow **Path B: CLI Setup** below.
17
+ - **Telegram or other channel** (no browser automation): Follow **Path A: Manual Setup for Channels** below.
18
18
 
19
19
  ---
20
20
 
@@ -29,6 +29,7 @@ Tell the user:
29
29
  > **Setting up Gmail & Calendar from Telegram**
30
30
  >
31
31
  > Since I can't automate the browser from here, I'll walk you through each step with direct links. You'll need:
32
+ >
32
33
  > 1. A Google account with access to Google Cloud Console
33
34
  > 2. About 5 minutes
34
35
  >
@@ -96,6 +97,7 @@ Tell the user:
96
97
  ### Channel Step 5: Create OAuth Credentials (Web Application)
97
98
 
98
99
  Before sending Step 4 to the user, resolve the concrete callback URL:
100
+
99
101
  - Read the configured public gateway URL (`ingress.publicBaseUrl`). If it is missing, run the `public-ingress` skill first.
100
102
  - Build `oauthCallbackUrl` as `<public gateway URL>/webhooks/oauth/callback`.
101
103
  - When you send the instructions below, replace `OAUTH_CALLBACK_URL` with that concrete value. Never send placeholders literally.
@@ -140,7 +142,7 @@ credential_store store:
140
142
 
141
143
  **Step 6b: Client Secret (requires split entry to avoid security filters)**
142
144
 
143
- The Client Secret starts with `GOCSPX-` which triggers the ingress secret scanner on channel messages. To work around this, ask the user to send only the portion *after* the prefix.
145
+ The Client Secret starts with `GOCSPX-` which triggers the ingress secret scanner on channel messages. To work around this, ask the user to send only the portion _after_ the prefix.
144
146
 
145
147
  Tell the user:
146
148
 
@@ -192,229 +194,165 @@ After the user authorizes (they'll come back and say so, or you can suggest they
192
194
 
193
195
  ---
194
196
 
195
- # Path B: Automated Setup (macOS Desktop App)
196
-
197
- You will automate the entire GCP setup via the browser while the user watches in the Chrome window on the side. The user's only manual actions are: signing in to their Google account, and copy-pasting credentials from the Chrome window into secure prompts.
198
-
199
- ## Browser Interaction Principles
200
-
201
- Google Cloud Console's UI changes frequently. Do NOT memorize or depend on specific element IDs, CSS selectors, or DOM structures. Instead:
202
-
203
- 1. **Snapshot first, act second.** Before every interaction, use `browser_snapshot` to discover interactive elements and their IDs. This is your primary navigation tool; it gives you the accessibility tree with clickable/typeable element IDs. Use `browser_screenshot` for visual context when the snapshot alone isn't enough.
204
- 2. **Adapt to what you see.** If an element's label or position differs from what you expect, use the snapshot to find the correct element. GCP may rename buttons, reorganize menus, or change form layouts at any time.
205
- 3. **Verify after every action.** After clicking, typing, or navigating, take a new snapshot to confirm the action succeeded. If it didn't, try an alternative interaction (e.g., if a dropdown didn't open on click, try pressing Space or Enter on the element).
206
- 4. **Never assume DOM structure.** Dropdowns may be `<select>`, `<mat-select>`, `<div role="listbox">`, or something else entirely. Use the snapshot to identify element types and interact accordingly.
207
- 5. **When stuck after 2 attempts, describe and ask.** Take a screenshot, describe what you see to the user, and ask for guidance.
208
-
209
- ## Anti-Loop Guardrails
197
+ # Path B: CLI Setup (macOS Desktop App)
210
198
 
211
- Each step has a **retry budget of 3 attempts**. An attempt is one try at the step's primary action (e.g., clicking a button, filling a form). If a step fails after 3 attempts:
199
+ **IMPORTANT: Always use `host_bash` (not `bash`) for all commands in this path.** The `gcloud` and `gws` CLIs need host access for Homebrew/npm installation, browser-based authentication, and interactive terminal prompts none of which are available inside the sandbox.
212
200
 
213
- 1. **Stop trying.** Do not continue retrying the same approach.
214
- 2. **Fall back to manual.** Tell the user what you were trying to do and ask them to complete that step manually in the Chrome window (which they can see on the side). Give them the direct URL and clear text instructions.
215
- 3. **Resume automation** at the next step once the user confirms the manual step is done.
201
+ You will set up Google Cloud OAuth credentials using the `gcloud` and `gws` command-line tools. This avoids browser automation entirely the user only needs to sign in once via the browser and copy-paste credentials from terminal output into secure prompts.
216
202
 
217
- If **two or more steps** require manual fallback, abandon the automated flow entirely and switch to giving the user the remaining steps as clear text instructions with links, using "Desktop app" as the OAuth application type.
203
+ ## CLI Step 1: Confirm
218
204
 
219
- ## Things That Do Not Work: Do Not Attempt
220
-
221
- These actions are technically impossible in the browser automation environment. Attempting them wastes time and leads to loops:
222
-
223
- - **Downloading files.** `browser_click` on a Download button does not save files to disk. There is NO JSON file to find at `~/Downloads` or anywhere else. Never click Download buttons.
224
- - **Clipboard operations.** You cannot copy/paste via browser automation. The user must manually copy values from the Chrome window.
225
- - **Deleting and recreating OAuth clients** to get a fresh secret. This orphans the stored client_id and causes `invalid_client` errors.
226
- - **Navigating away from the credential dialog** before both credentials are stored. You will lose the Client Secret display and cannot get it back without creating a new client.
227
-
228
- ## Step 1: Single Upfront Confirmation
229
-
230
- Use `ui_show` with `surface_type: "confirmation"`. Set `message` to just the title, and `detail` to the body:
205
+ Use `ui_show` with `surface_type: "confirmation"`:
231
206
 
232
207
  - **message:** `Set up Google Cloud for Gmail & Calendar`
233
208
  - **detail:**
234
209
  > Here's what will happen:
235
- > 1. **A browser opens on the side** so you can watch everything I do
236
- > 2. **You sign in** to your Google account in the browser
237
- > 3. **I automate everything** including project creation, APIs, OAuth config, and credentials
238
- > 4. **One copy-paste** where I'll ask you to copy the Client Secret from the browser into a secure prompt
210
+ >
211
+ > 1. **Install CLI tools** (`gcloud` and `gws`) if not already installed
212
+ > 2. **You sign in** to your Google account once via the browser
213
+ > 3. **CLI automates everything** project creation, APIs, consent screen, and credentials
214
+ > 4. **You copy-paste credentials** from the terminal output into secure prompts
239
215
  > 5. **You authorize Vellum** with one click
240
216
  >
241
- > The whole thing takes 2-3 minutes. Ready?
242
-
243
- If the user declines, acknowledge and stop. No further confirmations are needed after this point.
244
-
245
- ## Step 2: Open Google Cloud Console and Sign In
217
+ > Takes about a minute after first-time setup. Ready?
246
218
 
247
- **Goal:** The user is signed in and the Google Cloud Console dashboard is loaded.
248
-
249
- Navigate to `https://console.cloud.google.com/`.
250
-
251
- Take a screenshot to check the page state:
252
-
253
- - **Sign-in page:** Tell the user: "Please sign in to your Google account in the Chrome window on the right side of your screen." Then auto-detect sign-in completion by polling with `browser_screenshot` every 5-10 seconds to check if the URL has moved away from `accounts.google.com` to `console.cloud.google.com`. Do NOT ask the user to "let me know when you're done"; detect it automatically. Once sign-in is detected, tell the user: "Signed in! Starting the automated setup now..."
254
- - **Already signed in:** Tell the user: "Already signed in, starting setup now..." and continue immediately.
255
- - **CAPTCHA:** The browser automation's built-in handoff will handle this. If it persists, tell the user: "There's a CAPTCHA in the browser, please complete it and I'll continue automatically."
256
-
257
- **What you should see when done:** URL contains `console.cloud.google.com` and no sign-in overlay is visible.
258
-
259
- ## Step 3: Create or Select a Project
260
-
261
- **Goal:** A GCP project named "Vellum Assistant" exists and is selected.
262
-
263
- Tell the user: "Creating Google Cloud project..."
264
-
265
- Navigate to `https://console.cloud.google.com/projectcreate`.
266
-
267
- Take a `browser_snapshot`. Find the project name input field (look for an element with label containing "Project name" or a text input near the top of the form). Type "Vellum Assistant" into it.
268
-
269
- Look for a "Create" button in the snapshot and click it. Wait 10-15 seconds for project creation, then take a screenshot to check for:
270
- - **Success message** or redirect to the new project dashboard. Note the project ID from the URL or page content.
271
- - **"Project name already in use" error**: that's fine. Navigate to `https://console.cloud.google.com/cloud-resource-manager` to find and select the existing "Vellum Assistant" project. Use `browser_extract` to read the project ID from the page.
272
- - **Organization restriction or quota error**: tell the user what happened and ask them to resolve it.
273
-
274
- **What you should see when done:** The project selector in the top bar shows the project name, and you have the project ID (something like `vellum-assistant-12345`).
219
+ If the user declines, acknowledge and stop.
275
220
 
276
- Tell the user: "Project created!"
221
+ ## CLI Step 2: Install Prerequisites
277
222
 
278
- ## Step 4: Enable Gmail and Calendar APIs
223
+ Check for and install each prerequisite. If any installation fails (e.g., Homebrew not available, corporate restrictions), tell the user what went wrong and provide manual installation instructions.
279
224
 
280
- **Goal:** Both the Gmail API and Google Calendar API are enabled for the project.
225
+ ### gcloud
281
226
 
282
- Tell the user: "Enabling Gmail and Calendar APIs..."
227
+ ```bash
228
+ which gcloud
229
+ ```
283
230
 
284
- Navigate to each API's library page and enable it if not already enabled:
285
- 1. `https://console.cloud.google.com/apis/library/gmail.googleapis.com?project=PROJECT_ID`
286
- 2. `https://console.cloud.google.com/apis/library/calendar-json.googleapis.com?project=PROJECT_ID`
231
+ If missing:
287
232
 
288
- For each page: take a `browser_snapshot`. Look for:
289
- - **"Enable" button**: click it, wait a few seconds, take another snapshot to confirm.
290
- - **"Manage" button or "API enabled" text**: the API is already enabled. Skip it.
233
+ ```bash
234
+ brew install google-cloud-sdk
235
+ ```
291
236
 
292
- **What you should see when done:** Both API pages show "Manage" or "API enabled" status.
237
+ After installation, verify it works:
293
238
 
294
- Tell the user: "APIs enabled!"
239
+ ```bash
240
+ gcloud --version
241
+ ```
295
242
 
296
- ## Step 5: Configure OAuth Consent Screen
243
+ ### gws
297
244
 
298
- **Goal:** An OAuth consent screen is configured with External user type, the required scopes, and the user added as a test user.
245
+ ```bash
246
+ which gws
247
+ ```
299
248
 
300
- Tell the user: "Setting up OAuth consent screen. This is the longest step but it's fully automated..."
249
+ If missing:
301
250
 
302
- Navigate to `https://console.cloud.google.com/apis/credentials/consent?project=PROJECT_ID`.
251
+ ```bash
252
+ npm install -g @googleworkspace/cli
253
+ ```
303
254
 
304
- Take a `browser_snapshot` and `browser_screenshot`. Check the page state:
255
+ After installation, verify it works:
305
256
 
306
- ### If the consent screen is already configured
257
+ ```bash
258
+ gws --version
259
+ ```
307
260
 
308
- You'll see a dashboard showing the app name ("Vellum Assistant" or similar) with an "Edit App" button. **Skip to Step 6.**
261
+ ## CLI Step 3: Sign In to Google
309
262
 
310
- ### If you see a user type selection (External / Internal)
263
+ Tell the user: "Opening your browser so you can sign in to Google..."
311
264
 
312
- Select **"External"** and click **Create** or **Get Started**.
265
+ ```bash
266
+ gcloud auth login
267
+ ```
313
268
 
314
- ### Consent screen form (wizard or single-page)
269
+ This opens the browser for Google sign-in. Wait for the command to complete — it prints the authenticated account email on success.
315
270
 
316
- Google Cloud uses either a multi-page wizard or a single-page form. Adapt to what you see:
271
+ If the user is already authenticated (`gcloud auth list` shows an active account), skip this step and tell the user: "Already signed in, continuing setup..."
317
272
 
318
- **App information section:**
319
- - **App name**: Type "Vellum Assistant" in the app name field.
320
- - **User support email**: This is typically a dropdown showing the signed-in user's email. Use `browser_snapshot` to find a `<select>` or clickable dropdown element near "User support email". Select the user's email.
321
- - **Developer contact email**: Type the user's email into this field. (Use the same email visible in the support email dropdown if you can read it, or use `browser_extract` to find the email shown on the page.)
322
- - Click **Save and Continue** if on a multi-page wizard.
273
+ ## CLI Step 4: GCP Project Setup
323
274
 
324
- **Scopes section:**
325
- - Click **"Add or Remove Scopes"** (or similar button).
326
- - In the scope picker dialog, look for a text input labeled **"Manually add scopes"** or **"Filter"** at the bottom or top of the dialog.
327
- - Paste all 6 scopes at once as a comma-separated string into that input:
328
- ```
329
- https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/gmail.modify,https://www.googleapis.com/auth/gmail.send,https://www.googleapis.com/auth/calendar.readonly,https://www.googleapis.com/auth/calendar.events,https://www.googleapis.com/auth/userinfo.email
330
- ```
331
- - Click **"Add to Table"** or **"Update"** to confirm the scopes.
332
- - If no manual input is available, you'll need to search for and check each scope individually using the scope tree. Search for each scope URL in the filter box and check its checkbox.
333
- - Click **Save and Continue** (or **Update** then **Save and Continue**).
275
+ Tell the user: "Setting up your Google Cloud project, APIs, and credentials..."
334
276
 
335
- **Test users section:**
336
- - Click **"Add Users"** or similar.
337
- - Enter the user's email address.
338
- - Click **Add** then **Save and Continue**.
277
+ ```bash
278
+ gws auth setup
279
+ ```
339
280
 
340
- **Summary section:**
341
- - Click **"Back to Dashboard"** or **"Submit"**.
281
+ This command automates:
342
282
 
343
- **What you should see when done:** A consent screen dashboard showing "Vellum Assistant" as the app name.
283
+ - GCP project creation (or selection of an existing one)
284
+ - OAuth consent screen configuration
285
+ - OAuth credential creation
344
286
 
345
- Tell the user: "Consent screen configured!"
287
+ Wait for the command to complete. It may have interactive prompts — let them run in the terminal and the user can respond if needed.
346
288
 
347
- ## Step 6: Create OAuth Credentials and Capture Them
289
+ Note the **project ID** from the output you'll need it for the next step.
348
290
 
349
- **Goal:** A "Desktop app" OAuth client exists, and both its Client ID and Client Secret are stored in the vault.
291
+ ## CLI Step 5: Enable Additional APIs
350
292
 
351
- Tell the user: "Creating OAuth credentials..."
293
+ `gws auth setup` enables the APIs it needs, but Vellum also requires the Calendar and People APIs. Enable them explicitly using the project ID from step 4:
352
294
 
353
- ### 6a: Create the credential
295
+ ```bash
296
+ gcloud services enable calendar-json.googleapis.com --project=PROJECT_ID
297
+ gcloud services enable people.googleapis.com --project=PROJECT_ID
298
+ ```
354
299
 
355
- Navigate to `https://console.cloud.google.com/apis/credentials?project=PROJECT_ID`.
300
+ If either command reports the API is already enabled, that's fine — continue.
356
301
 
357
- Take a `browser_snapshot`. Find and click a button labeled **"Create Credentials"** or **"+ Create Credentials"**. A dropdown menu should appear. Take another snapshot and click **"OAuth client ID"**.
302
+ ## CLI Step 5b: Update OAuth Consent Screen Scopes
358
303
 
359
- On the creation form (take a snapshot to see the fields):
360
- - **Application type**: Find the dropdown and select **"Desktop app"**. This may be a `<select>` element or a custom dropdown. Use the snapshot to identify it. You might need to click the dropdown first, then take another snapshot to see the options, then click "Desktop app".
361
- - **Name**: Type "Vellum Assistant" in the name field.
362
- - Do NOT add any redirect URIs. The desktop app flow doesn't need them.
304
+ `gws auth setup` configures the consent screen with Gmail scopes only. Vellum also needs Calendar, Contacts, and userinfo scopes. Tell the user to add the missing scopes:
363
305
 
364
- Click **"Create"** to submit the form.
306
+ > **Update consent screen scopes**
307
+ >
308
+ > Open: `https://console.cloud.google.com/apis/credentials/consent/edit?project=PROJECT_ID`
309
+ >
310
+ > 1. Click through to the **Scopes** page (click **Save and Continue** on the first page)
311
+ > 2. Click **Add or Remove Scopes** and ensure all of these are present:
312
+ > - `https://www.googleapis.com/auth/gmail.readonly`
313
+ > - `https://www.googleapis.com/auth/gmail.modify`
314
+ > - `https://www.googleapis.com/auth/gmail.send`
315
+ > - `https://www.googleapis.com/auth/calendar.readonly`
316
+ > - `https://www.googleapis.com/auth/calendar.events`
317
+ > - `https://www.googleapis.com/auth/userinfo.email`
318
+ > - `https://www.googleapis.com/auth/contacts.readonly`
319
+ > 3. Click **Update**, then **Save and Continue** through the remaining pages
365
320
 
366
- ### 6b: Capture credentials from the dialog
321
+ (Substitute the actual project ID into the URL.)
367
322
 
368
- After creation, a dialog will display the **Client ID** and **Client Secret**. This is the critical step.
323
+ The Gmail scopes may already be present from `gws auth setup` add any that are missing.
369
324
 
370
- **First**, try to auto-read the **Client ID** using `browser_extract`. The Client ID matches the pattern `*.apps.googleusercontent.com`. Search the extracted text for this pattern. If found, store it:
325
+ ## CLI Step 6: Collect Credentials
371
326
 
372
- ```
373
- credential_store store:
374
- service: "integration:gmail"
375
- field: "client_id"
376
- value: "<the Client ID extracted from the page>"
377
- ```
327
+ The `gws auth setup` output or the GCP Console shows the Client ID and Client Secret. Ask the user to copy-paste them into secure prompts.
378
328
 
379
- If `browser_extract` fails to find the Client ID, prompt the user instead:
329
+ **Client ID:**
380
330
 
381
331
  ```
382
332
  credential_store prompt:
383
333
  service: "integration:gmail"
384
334
  field: "client_id"
385
335
  label: "Google OAuth Client ID"
386
- description: "Copy the Client ID from the dialog in the Chrome window and paste it here. It looks like 123456789-xxxxx.apps.googleusercontent.com"
336
+ description: "Copy the Client ID from the setup output or GCP Console. It looks like 123456789-xxxxx.apps.googleusercontent.com"
387
337
  placeholder: "xxxxx.apps.googleusercontent.com"
388
338
  ```
389
339
 
390
- **Then**, whether the Client ID was auto-read or prompted, tell the user:
391
-
392
- > "Got the Client ID! Now I need the Client Secret. You can see it in the dialog in the Chrome window. It starts with `GOCSPX-`. Please copy it and paste it into the secure prompt below."
393
-
394
- And present the secure prompt:
340
+ **Client Secret:**
395
341
 
396
342
  ```
397
343
  credential_store prompt:
398
344
  service: "integration:gmail"
399
345
  field: "client_secret"
400
346
  label: "Google OAuth Client Secret"
401
- description: "Copy the Client Secret from the Google Cloud Console dialog and paste it here."
347
+ description: "Copy the Client Secret from the setup output or GCP Console. It starts with GOCSPX-"
402
348
  placeholder: "GOCSPX-..."
403
349
  ```
404
350
 
405
- Wait for the user to complete the prompt. **Do not take any other browser actions until the user has pasted the secret.** The dialog must stay open so they can see and copy the value.
406
-
407
- If the user has trouble locating the secret, take a `browser_screenshot` and describe where the secret field is on the screen, but do NOT attempt to read the secret value yourself. It must come from the user for accuracy.
408
-
409
- **What you should see when done:** `credential_store list` shows both `client_id` and `client_secret` for `integration:gmail`.
410
-
411
- Tell the user: "Credentials stored securely!"
351
+ Wait for both prompts to be completed before continuing.
412
352
 
413
- ## Step 7: OAuth2 Authorization
353
+ ## CLI Step 7: Authorize
414
354
 
415
- **Goal:** The user authorizes Vellum to access their Gmail and Calendar via OAuth.
416
-
417
- Tell the user: "Starting the authorization flow — a Google sign-in page will open in a few seconds. Just click 'Allow' when it appears."
355
+ Tell the user: "Starting the authorization flow a Google sign-in page will open. Just click 'Allow' when it appears."
418
356
 
419
357
  Use `credential_store` with:
420
358
 
@@ -429,15 +367,6 @@ This auto-reads client_id and client_secret from the secure store and auto-fills
429
367
 
430
368
  **Verify:** The `oauth2_connect` call returns a success message with the connected account email.
431
369
 
432
- ## Step 8: Done!
370
+ ## CLI Step 8: Done!
433
371
 
434
372
  Tell the user: "**Gmail and Calendar are connected!** You can now read, search, and send emails, plus view and manage your calendar. Try asking me to check your inbox or show your upcoming events!"
435
-
436
- ## Error Handling
437
-
438
- - **Page load failures:** Retry navigation once. If it still fails, tell the user and ask them to check their internet connection.
439
- - **Permission errors in GCP:** The user may need billing enabled or organization-level permissions. Explain clearly and ask them to resolve it.
440
- - **Consent screen already configured:** Don't overwrite. Skip to credential creation.
441
- - **Element not found:** Take a fresh `browser_snapshot` to re-assess. The GCP UI may have changed. Describe what you see and try alternative approaches. If stuck after 2 attempts, ask the user for guidance. They can see the Chrome window too.
442
- - **OAuth flow timeout or failure:** Offer to retry. The credentials are already stored, so reconnecting only requires re-running the authorization flow.
443
- - **Any unexpected state:** Take a `browser_screenshot`, describe what you see, and ask the user for guidance.
@@ -36,7 +36,6 @@ import {
36
36
  migrateBackfillContactInteractionStats,
37
37
  migrateBackfillGuardianPrincipalId,
38
38
  migrateCallSessionMode,
39
- migrateDropAssistantIdColumns,
40
39
  migrateCanonicalGuardianDeliveriesDestinationIndex,
41
40
  migrateCanonicalGuardianRequesterChatId,
42
41
  migrateChannelInboundDeliveredSegments,
@@ -46,6 +45,7 @@ import {
46
45
  migrateContactsNotesColumn,
47
46
  migrateContactsRolePrincipal,
48
47
  migrateConversationsThreadTypeIndex,
48
+ migrateDropAssistantIdColumns,
49
49
  migrateDropLegacyMemberGuardianTables,
50
50
  migrateFkCascadeRebuilds,
51
51
  migrateGuardianActionFollowup,
@@ -1,4 +1,5 @@
1
1
  import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
2
3
 
3
4
  /**
4
5
  * Extend channel_guardian_verification_challenges with outbound verification
@@ -83,13 +84,31 @@ export function migrateGuardianVerificationSessions(database: DrizzleDb): void {
83
84
  }
84
85
 
85
86
  // -- Indexes for session lookups --
86
- database.run(
87
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_active ON channel_guardian_verification_challenges(assistant_id, channel, status)`,
88
- );
89
- database.run(
90
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_identity ON channel_guardian_verification_challenges(assistant_id, channel, expected_external_user_id, expected_chat_id, status)`,
91
- );
92
- database.run(
93
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_destination ON channel_guardian_verification_challenges(assistant_id, channel, destination_address)`,
94
- );
87
+ if (
88
+ tableHasColumn(
89
+ database,
90
+ "channel_guardian_verification_challenges",
91
+ "assistant_id",
92
+ )
93
+ ) {
94
+ database.run(
95
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_active ON channel_guardian_verification_challenges(assistant_id, channel, status)`,
96
+ );
97
+ database.run(
98
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_identity ON channel_guardian_verification_challenges(assistant_id, channel, expected_external_user_id, expected_chat_id, status)`,
99
+ );
100
+ database.run(
101
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_destination ON channel_guardian_verification_challenges(assistant_id, channel, destination_address)`,
102
+ );
103
+ } else {
104
+ database.run(
105
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_active ON channel_guardian_verification_challenges(channel, status)`,
106
+ );
107
+ database.run(
108
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_identity ON channel_guardian_verification_challenges(channel, expected_external_user_id, expected_chat_id, status)`,
109
+ );
110
+ database.run(
111
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_destination ON channel_guardian_verification_challenges(channel, destination_address)`,
112
+ );
113
+ }
95
114
  }
@@ -1,4 +1,5 @@
1
1
  import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
2
3
 
3
4
  /**
4
5
  * Add bootstrap_token_hash column to channel_guardian_verification_challenges.
@@ -16,7 +17,19 @@ export function migrateGuardianBootstrapToken(database: DrizzleDb): void {
16
17
  }
17
18
 
18
19
  // Index for looking up pending_bootstrap sessions by bootstrap token hash
19
- database.run(
20
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_bootstrap ON channel_guardian_verification_challenges(assistant_id, channel, bootstrap_token_hash, status)`,
21
- );
20
+ if (
21
+ tableHasColumn(
22
+ database,
23
+ "channel_guardian_verification_challenges",
24
+ "assistant_id",
25
+ )
26
+ ) {
27
+ database.run(
28
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_bootstrap ON channel_guardian_verification_challenges(assistant_id, channel, bootstrap_token_hash, status)`,
29
+ );
30
+ } else {
31
+ database.run(
32
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_guardian_sessions_bootstrap ON channel_guardian_verification_challenges(channel, bootstrap_token_hash, status)`,
33
+ );
34
+ }
22
35
  }
@@ -1,4 +1,5 @@
1
1
  import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
2
3
 
3
4
  /**
4
5
  * Create the actor_token_records table for hash-only actor token persistence.
@@ -24,9 +25,15 @@ export function createActorTokenRecordsTable(database: DrizzleDb): void {
24
25
  `);
25
26
 
26
27
  // Unique active token per device binding
27
- database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_actor_tokens_active_device
28
+ if (tableHasColumn(database, "actor_token_records", "assistant_id")) {
29
+ database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_actor_tokens_active_device
28
30
  ON actor_token_records(assistant_id, guardian_principal_id, hashed_device_id)
29
31
  WHERE status = 'active'`);
32
+ } else {
33
+ database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_actor_tokens_active_device
34
+ ON actor_token_records(guardian_principal_id, hashed_device_id)
35
+ WHERE status = 'active'`);
36
+ }
30
37
 
31
38
  // Token hash lookup for verification
32
39
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_actor_tokens_hash
@@ -1,4 +1,5 @@
1
1
  import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
2
3
 
3
4
  /**
4
5
  * Create the actor_refresh_token_records table for hash-only refresh token persistence.
@@ -34,10 +35,18 @@ export function createActorRefreshTokenRecordsTable(database: DrizzleDb): void {
34
35
  // Unique active refresh token per device binding.
35
36
  // DROP first so that databases that already created the older non-unique
36
37
  // index with the same name get upgraded to UNIQUE.
37
- database.run(/*sql*/ `DROP INDEX IF EXISTS idx_refresh_tokens_active_device`);
38
- database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active_device
38
+ if (tableHasColumn(database, "actor_refresh_token_records", "assistant_id")) {
39
+ database.run(
40
+ /*sql*/ `DROP INDEX IF EXISTS idx_refresh_tokens_active_device`,
41
+ );
42
+ database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active_device
39
43
  ON actor_refresh_token_records(assistant_id, guardian_principal_id, hashed_device_id)
40
44
  WHERE status = 'active'`);
45
+ } else {
46
+ database.run(/*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_refresh_tokens_active_device
47
+ ON actor_refresh_token_records(guardian_principal_id, hashed_device_id)
48
+ WHERE status = 'active'`);
49
+ }
41
50
 
42
51
  // Family lookup for replay detection (revoke entire family)
43
52
  database.run(/*sql*/ `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family
@@ -1,4 +1,5 @@
1
1
  import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
2
3
 
3
4
  /**
4
5
  * Channel guardian tables: bindings, verification challenges, approval requests,
@@ -43,9 +44,21 @@ export function createChannelGuardianTables(database: DrizzleDb): void {
43
44
  )
44
45
  `);
45
46
 
46
- database.run(
47
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_guardian_challenges_lookup ON channel_guardian_verification_challenges(assistant_id, channel, challenge_hash, status)`,
48
- );
47
+ if (
48
+ tableHasColumn(
49
+ database,
50
+ "channel_guardian_verification_challenges",
51
+ "assistant_id",
52
+ )
53
+ ) {
54
+ database.run(
55
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_guardian_challenges_lookup ON channel_guardian_verification_challenges(assistant_id, channel, challenge_hash, status)`,
56
+ );
57
+ } else {
58
+ database.run(
59
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_channel_guardian_challenges_lookup ON channel_guardian_verification_challenges(channel, challenge_hash, status)`,
60
+ );
61
+ }
49
62
 
50
63
  database.run(/*sql*/ `
51
64
  CREATE TABLE IF NOT EXISTS channel_guardian_approval_requests (
@@ -139,7 +152,15 @@ export function createChannelGuardianTables(database: DrizzleDb): void {
139
152
  /* already exists */
140
153
  }
141
154
 
142
- database.run(
143
- /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_guardian_rate_limits_actor ON channel_guardian_rate_limits(assistant_id, channel, actor_external_user_id, actor_chat_id)`,
144
- );
155
+ if (
156
+ tableHasColumn(database, "channel_guardian_rate_limits", "assistant_id")
157
+ ) {
158
+ database.run(
159
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_guardian_rate_limits_actor ON channel_guardian_rate_limits(assistant_id, channel, actor_external_user_id, actor_chat_id)`,
160
+ );
161
+ } else {
162
+ database.run(
163
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_guardian_rate_limits_actor ON channel_guardian_rate_limits(channel, actor_external_user_id, actor_chat_id)`,
164
+ );
165
+ }
145
166
  }
@@ -1,5 +1,6 @@
1
1
  import type { DrizzleDb } from "../db-connection.js";
2
2
  import { migrateBackfillInboxThreadStateFromBindings } from "./014-backfill-inbox-thread-state.js";
3
+ import { tableHasColumn } from "./schema-introspection.js";
3
4
 
4
5
  /**
5
6
  * Assistant inbox tables: ingress invites, ingress members, inbox thread state.
@@ -28,12 +29,21 @@ export function createAssistantInboxTables(database: DrizzleDb): void {
28
29
  database.run(
29
30
  /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_ingress_invites_token_hash ON assistant_ingress_invites(token_hash)`,
30
31
  );
31
- database.run(
32
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_status ON assistant_ingress_invites(assistant_id, source_channel, status, expires_at)`,
33
- );
34
- database.run(
35
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_created ON assistant_ingress_invites(assistant_id, source_channel, created_at)`,
36
- );
32
+ if (tableHasColumn(database, "assistant_ingress_invites", "assistant_id")) {
33
+ database.run(
34
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_status ON assistant_ingress_invites(assistant_id, source_channel, status, expires_at)`,
35
+ );
36
+ database.run(
37
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_created ON assistant_ingress_invites(assistant_id, source_channel, created_at)`,
38
+ );
39
+ } else {
40
+ database.run(
41
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_status ON assistant_ingress_invites(source_channel, status, expires_at)`,
42
+ );
43
+ database.run(
44
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_ingress_invites_channel_created ON assistant_ingress_invites(source_channel, created_at)`,
45
+ );
46
+ }
37
47
 
38
48
  database.run(/*sql*/ `
39
49
  CREATE TABLE IF NOT EXISTS assistant_ingress_members (
@@ -90,15 +100,29 @@ export function createAssistantInboxTables(database: DrizzleDb): void {
90
100
  )
91
101
  `);
92
102
 
93
- database.run(
94
- /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_thread_state_channel ON assistant_inbox_thread_state(assistant_id, source_channel, external_chat_id)`,
95
- );
96
- database.run(
97
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_last_msg ON assistant_inbox_thread_state(assistant_id, last_message_at)`,
98
- );
99
- database.run(
100
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_escalation ON assistant_inbox_thread_state(assistant_id, has_pending_escalation, last_message_at)`,
101
- );
103
+ if (
104
+ tableHasColumn(database, "assistant_inbox_thread_state", "assistant_id")
105
+ ) {
106
+ database.run(
107
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_thread_state_channel ON assistant_inbox_thread_state(assistant_id, source_channel, external_chat_id)`,
108
+ );
109
+ database.run(
110
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_last_msg ON assistant_inbox_thread_state(assistant_id, last_message_at)`,
111
+ );
112
+ database.run(
113
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_escalation ON assistant_inbox_thread_state(assistant_id, has_pending_escalation, last_message_at)`,
114
+ );
115
+ } else {
116
+ database.run(
117
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_inbox_thread_state_channel ON assistant_inbox_thread_state(source_channel, external_chat_id)`,
118
+ );
119
+ database.run(
120
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_last_msg ON assistant_inbox_thread_state(last_message_at)`,
121
+ );
122
+ database.run(
123
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_inbox_thread_state_escalation ON assistant_inbox_thread_state(has_pending_escalation, last_message_at)`,
124
+ );
125
+ }
102
126
 
103
127
  migrateBackfillInboxThreadStateFromBindings(database);
104
128
  }
@@ -2,6 +2,7 @@ import type { DrizzleDb } from "../db-connection.js";
2
2
  import { migrateNotificationTablesSchema } from "./019-notification-tables-schema-migration.js";
3
3
  import { migrateNotificationDeliveryPairingColumns } from "./027-notification-delivery-pairing-columns.js";
4
4
  import { migrateNotificationDeliveryClientAck } from "./028-notification-delivery-client-ack.js";
5
+ import { tableHasColumn } from "./schema-introspection.js";
5
6
 
6
7
  /**
7
8
  * Notification system tables: preferences, events, decisions, and deliveries.
@@ -24,12 +25,18 @@ export function createNotificationTables(database: DrizzleDb): void {
24
25
  )
25
26
  `);
26
27
 
27
- database.run(
28
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_id ON notification_preferences(assistant_id)`,
29
- );
30
- database.run(
31
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_priority ON notification_preferences(assistant_id, priority DESC)`,
32
- );
28
+ if (tableHasColumn(database, "notification_preferences", "assistant_id")) {
29
+ database.run(
30
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_id ON notification_preferences(assistant_id)`,
31
+ );
32
+ database.run(
33
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_assistant_priority ON notification_preferences(assistant_id, priority DESC)`,
34
+ );
35
+ } else {
36
+ database.run(
37
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_preferences_priority ON notification_preferences(priority DESC)`,
38
+ );
39
+ }
33
40
 
34
41
  database.run(/*sql*/ `
35
42
  CREATE TABLE IF NOT EXISTS notification_events (
@@ -46,12 +53,21 @@ export function createNotificationTables(database: DrizzleDb): void {
46
53
  )
47
54
  `);
48
55
 
49
- database.run(
50
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_events_assistant_event_created ON notification_events(assistant_id, source_event_name, created_at)`,
51
- );
52
- database.run(
53
- /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_events_dedupe ON notification_events(assistant_id, dedupe_key) WHERE dedupe_key IS NOT NULL`,
54
- );
56
+ if (tableHasColumn(database, "notification_events", "assistant_id")) {
57
+ database.run(
58
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_events_assistant_event_created ON notification_events(assistant_id, source_event_name, created_at)`,
59
+ );
60
+ database.run(
61
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_events_dedupe ON notification_events(assistant_id, dedupe_key) WHERE dedupe_key IS NOT NULL`,
62
+ );
63
+ } else {
64
+ database.run(
65
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_events_event_created ON notification_events(source_event_name, created_at)`,
66
+ );
67
+ database.run(
68
+ /*sql*/ `CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_events_dedupe ON notification_events(dedupe_key) WHERE dedupe_key IS NOT NULL`,
69
+ );
70
+ }
55
71
 
56
72
  database.run(/*sql*/ `
57
73
  CREATE TABLE IF NOT EXISTS notification_decisions (
@@ -97,9 +113,15 @@ export function createNotificationTables(database: DrizzleDb): void {
97
113
  database.run(
98
114
  /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_decision_id ON notification_deliveries(notification_decision_id)`,
99
115
  );
100
- database.run(
101
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_assistant_status ON notification_deliveries(assistant_id, status)`,
102
- );
116
+ if (tableHasColumn(database, "notification_deliveries", "assistant_id")) {
117
+ database.run(
118
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_assistant_status ON notification_deliveries(assistant_id, status)`,
119
+ );
120
+ } else {
121
+ database.run(
122
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status ON notification_deliveries(status)`,
123
+ );
124
+ }
103
125
 
104
126
  // Add conversation pairing audit columns (idempotent ALTER TABLE)
105
127
  migrateNotificationDeliveryPairingColumns(database);
@@ -1,4 +1,5 @@
1
1
  import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
2
3
 
3
4
  /**
4
5
  * Conversation attention tables: append-only evidence log and single-row
@@ -24,9 +25,17 @@ export function createConversationAttentionTables(database: DrizzleDb): void {
24
25
  database.run(
25
26
  /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_conv_observed ON conversation_attention_events(conversation_id, observed_at DESC)`,
26
27
  );
27
- database.run(
28
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_assistant_observed ON conversation_attention_events(assistant_id, observed_at DESC)`,
29
- );
28
+ if (
29
+ tableHasColumn(database, "conversation_attention_events", "assistant_id")
30
+ ) {
31
+ database.run(
32
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_assistant_observed ON conversation_attention_events(assistant_id, observed_at DESC)`,
33
+ );
34
+ } else {
35
+ database.run(
36
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_observed ON conversation_attention_events(observed_at)`,
37
+ );
38
+ }
30
39
  database.run(
31
40
  /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_events_channel_observed ON conversation_attention_events(source_channel, observed_at DESC)`,
32
41
  );
@@ -50,10 +59,25 @@ export function createConversationAttentionTables(database: DrizzleDb): void {
50
59
  )
51
60
  `);
52
61
 
53
- database.run(
54
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_assistant_latest_msg ON conversation_assistant_attention_state(assistant_id, latest_assistant_message_at DESC)`,
55
- );
56
- database.run(
57
- /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_assistant_last_seen ON conversation_assistant_attention_state(assistant_id, last_seen_assistant_message_at DESC)`,
58
- );
62
+ if (
63
+ tableHasColumn(
64
+ database,
65
+ "conversation_assistant_attention_state",
66
+ "assistant_id",
67
+ )
68
+ ) {
69
+ database.run(
70
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_assistant_latest_msg ON conversation_assistant_attention_state(assistant_id, latest_assistant_message_at DESC)`,
71
+ );
72
+ database.run(
73
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_assistant_last_seen ON conversation_assistant_attention_state(assistant_id, last_seen_assistant_message_at DESC)`,
74
+ );
75
+ } else {
76
+ database.run(
77
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_latest_msg ON conversation_assistant_attention_state(latest_assistant_message_at)`,
78
+ );
79
+ database.run(
80
+ /*sql*/ `CREATE INDEX IF NOT EXISTS idx_conv_attn_state_last_seen ON conversation_assistant_attention_state(last_seen_assistant_message_at)`,
81
+ );
82
+ }
59
83
  }
@@ -0,0 +1,18 @@
1
+ import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
2
+
3
+ /**
4
+ * Startup still replays the historical table/index bootstrap helpers on every
5
+ * process launch, so migrations need a cheap way to branch on the live schema.
6
+ */
7
+ export function tableHasColumn(
8
+ database: DrizzleDb,
9
+ tableName: string,
10
+ columnName: string,
11
+ ): boolean {
12
+ const raw = getSqliteFrom(database);
13
+ const columns = raw.query(`PRAGMA table_info(${tableName})`).all() as Array<{
14
+ name: string;
15
+ }>;
16
+
17
+ return columns.some((column) => column.name === columnName);
18
+ }
@@ -127,6 +127,7 @@ import {
127
127
  } from "./routes/pairing-routes.js";
128
128
  import { secretRouteDefinitions } from "./routes/secret-routes.js";
129
129
  import { surfaceActionRouteDefinitions } from "./routes/surface-action-routes.js";
130
+ import { surfaceContentRouteDefinitions } from "./routes/surface-content-routes.js";
130
131
  import { trustRulesRouteDefinitions } from "./routes/trust-rules-routes.js";
131
132
  import { twilioRouteDefinitions } from "./routes/twilio-routes.js";
132
133
 
@@ -183,15 +184,7 @@ export class RuntimeHttpServer {
183
184
  private pairingStore = new PairingStore();
184
185
  private pairingBroadcast?: (msg: ServerMessage) => void;
185
186
  private sendMessageDeps?: SendMessageDeps;
186
- private findSession?: (sessionId: string) =>
187
- | {
188
- handleSurfaceAction(
189
- surfaceId: string,
190
- actionId: string,
191
- data?: Record<string, unknown>,
192
- ): void;
193
- }
194
- | undefined;
187
+ private findSession?: RuntimeHttpServerOptions["findSession"];
195
188
  private router: HttpRouter;
196
189
 
197
190
  constructor(options: RuntimeHttpServerOptions = {}) {
@@ -847,6 +840,7 @@ export class RuntimeHttpServer {
847
840
  ...approvalRouteDefinitions(),
848
841
  ...trustRulesRouteDefinitions(),
849
842
  ...surfaceActionRouteDefinitions({ findSession: this.findSession }),
843
+ ...surfaceContentRouteDefinitions({ findSession: this.findSession }),
850
844
  ...guardianActionRouteDefinitions(),
851
845
 
852
846
  ...contactRouteDefinitions(),
@@ -2,6 +2,7 @@
2
2
  * Shared types for the runtime HTTP server and its route handlers.
3
3
  */
4
4
  import type { ChannelId, InterfaceId } from "../channels/types.js";
5
+ import type { SurfaceData, SurfaceType } from "../daemon/ipc-contract/surfaces.js";
5
6
  import type { Session } from "../daemon/session.js";
6
7
  import type { TrustContext } from "../daemon/session-runtime-assembly.js";
7
8
  import type {
@@ -169,7 +170,7 @@ export interface RuntimeHttpServerOptions {
169
170
  guardianFollowUpConversationGenerator?: GuardianFollowUpConversationGenerator;
170
171
  /** Dependencies for the POST /v1/messages queue-if-busy handler. */
171
172
  sendMessageDeps?: SendMessageDeps;
172
- /** Lookup an active session by ID (for surface actions). Returns undefined if not found. */
173
+ /** Lookup an active session by ID (for surface actions and content fetches). */
173
174
  findSession?: (sessionId: string) =>
174
175
  | {
175
176
  handleSurfaceAction(
@@ -177,6 +178,17 @@ export interface RuntimeHttpServerOptions {
177
178
  actionId: string,
178
179
  data?: Record<string, unknown>,
179
180
  ): void;
181
+ surfaceState: Map<
182
+ string,
183
+ { surfaceType: SurfaceType; data: SurfaceData; title?: string }
184
+ >;
185
+ currentTurnSurfaces?: Array<{
186
+ surfaceId: string;
187
+ surfaceType: SurfaceType;
188
+ title?: string;
189
+ data: SurfaceData;
190
+ actions?: Array<{ id: string; label: string; style?: string }>;
191
+ }>;
180
192
  }
181
193
  | undefined;
182
194
  }
@@ -114,7 +114,7 @@ export async function handleGuardianBootstrap(
114
114
  );
115
115
  }
116
116
 
117
- if (platform !== "macos" && platform !== "cli") {
117
+ if (platform !== "macos" && platform !== "cli" && platform !== "web") {
118
118
  return httpError(
119
119
  "BAD_REQUEST",
120
120
  "Invalid platform. Bootstrap is macOS/CLI-only; iOS uses QR pairing.",
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Route handler for fetching surface content by ID.
3
+ *
4
+ * GET /v1/surfaces/:surfaceId — return the full surface payload from the
5
+ * session's in-memory surface state. Used by clients to re-hydrate surfaces
6
+ * whose data was stripped during memory compaction.
7
+ */
8
+ import type { SurfaceData, SurfaceType } from "../../daemon/ipc-contract/surfaces.js";
9
+ import { getLogger } from "../../util/logger.js";
10
+ import { httpError } from "../http-errors.js";
11
+ import type { RouteDefinition } from "../http-router.js";
12
+
13
+ const log = getLogger("surface-content-routes");
14
+
15
+ /** Narrow interface for looking up surface state from a session. */
16
+ interface SurfaceContentTarget {
17
+ surfaceState: Map<
18
+ string,
19
+ { surfaceType: SurfaceType; data: SurfaceData; title?: string }
20
+ >;
21
+ currentTurnSurfaces?: Array<{
22
+ surfaceId: string;
23
+ surfaceType: SurfaceType;
24
+ title?: string;
25
+ data: SurfaceData;
26
+ actions?: Array<{ id: string; label: string; style?: string }>;
27
+ }>;
28
+ }
29
+
30
+ export type SurfaceContentSessionLookup = (
31
+ sessionId: string,
32
+ ) => SurfaceContentTarget | undefined;
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Route definitions
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export function surfaceContentRouteDefinitions(deps: {
39
+ findSession?: SurfaceContentSessionLookup;
40
+ }): RouteDefinition[] {
41
+ return [
42
+ {
43
+ endpoint: "surfaces/:surfaceId",
44
+ method: "GET",
45
+ handler: ({ url, params }) => {
46
+ if (!deps.findSession) {
47
+ return httpError(
48
+ "NOT_IMPLEMENTED",
49
+ "Surface content lookup not available",
50
+ 501,
51
+ );
52
+ }
53
+
54
+ const sessionId = url.searchParams.get("sessionId");
55
+ if (!sessionId) {
56
+ return httpError("BAD_REQUEST", "sessionId query parameter is required", 400);
57
+ }
58
+
59
+ const surfaceId = params.surfaceId;
60
+ if (!surfaceId) {
61
+ return httpError("BAD_REQUEST", "surfaceId path parameter is required", 400);
62
+ }
63
+
64
+ const session = deps.findSession(sessionId);
65
+ if (!session) {
66
+ return httpError(
67
+ "NOT_FOUND",
68
+ "No active session found for this sessionId",
69
+ 404,
70
+ );
71
+ }
72
+
73
+ // Look up the surface in the session's in-memory state.
74
+ const stored = session.surfaceState.get(surfaceId);
75
+ if (stored) {
76
+ log.info({ sessionId, surfaceId }, "Surface content served from surfaceState");
77
+ return Response.json({
78
+ surfaceId,
79
+ surfaceType: stored.surfaceType,
80
+ title: stored.title ?? null,
81
+ data: stored.data,
82
+ });
83
+ }
84
+
85
+ // Fall back to currentTurnSurfaces in case the surface hasn't been
86
+ // committed to surfaceState yet (e.g. mid-turn).
87
+ const turnSurface = session.currentTurnSurfaces?.find(
88
+ (s) => s.surfaceId === surfaceId,
89
+ );
90
+ if (turnSurface) {
91
+ log.info({ sessionId, surfaceId }, "Surface content served from currentTurnSurfaces");
92
+ return Response.json({
93
+ surfaceId,
94
+ surfaceType: turnSurface.surfaceType,
95
+ title: turnSurface.title ?? null,
96
+ data: turnSurface.data,
97
+ });
98
+ }
99
+
100
+ return httpError("NOT_FOUND", "Surface not found in session", 404);
101
+ },
102
+ },
103
+ ];
104
+ }