@vellumai/assistant 0.4.18 → 0.4.20

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.
Files changed (36) hide show
  1. package/docs/runbook-trusted-contacts.md +5 -3
  2. package/package.json +1 -1
  3. package/src/__tests__/channel-approvals.test.ts +7 -1
  4. package/src/__tests__/conversation-routes-guardian-reply.test.ts +8 -0
  5. package/src/__tests__/daemon-server-session-init.test.ts +2 -0
  6. package/src/__tests__/gmail-integration.test.ts +13 -4
  7. package/src/__tests__/handle-user-message-secret-resume.test.ts +7 -1
  8. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +2 -0
  9. package/src/__tests__/ingress-reconcile.test.ts +13 -5
  10. package/src/__tests__/mcp-cli.test.ts +1 -1
  11. package/src/__tests__/recording-intent-handler.test.ts +9 -1
  12. package/src/__tests__/send-endpoint-busy.test.ts +8 -2
  13. package/src/__tests__/sms-messaging-provider.test.ts +4 -0
  14. package/src/__tests__/system-prompt.test.ts +18 -2
  15. package/src/__tests__/tool-execution-abort-cleanup.test.ts +1 -0
  16. package/src/agent/loop.ts +324 -163
  17. package/src/cli/mcp.ts +81 -28
  18. package/src/config/bundled-skills/app-builder/SKILL.md +7 -5
  19. package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
  20. package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +6 -11
  21. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -2
  22. package/src/config/bundled-skills/sms-setup/SKILL.md +8 -16
  23. package/src/config/bundled-skills/telegram-setup/SKILL.md +3 -3
  24. package/src/config/bundled-skills/trusted-contacts/SKILL.md +13 -25
  25. package/src/config/bundled-skills/twilio-setup/SKILL.md +13 -23
  26. package/src/config/system-prompt.ts +574 -518
  27. package/src/daemon/session-surfaces.ts +28 -0
  28. package/src/daemon/session.ts +255 -191
  29. package/src/daemon/tool-side-effects.ts +3 -13
  30. package/src/mcp/client.ts +2 -7
  31. package/src/security/secure-keys.ts +43 -3
  32. package/src/tools/apps/definitions.ts +5 -0
  33. package/src/tools/apps/executors.ts +18 -22
  34. package/src/tools/terminal/safe-env.ts +7 -0
  35. package/src/__tests__/response-tier.test.ts +0 -195
  36. package/src/daemon/response-tier.ts +0 -250
@@ -10,15 +10,11 @@
10
10
  import { join } from 'node:path';
11
11
 
12
12
  import { updatePublishedAppDeployment } from '../services/published-app-updater.js';
13
- import { openAppViaSurface } from '../tools/apps/open-proxy.js';
14
13
  import type { ToolExecutionResult } from '../tools/types.js';
15
14
  import { getWorkspaceDir } from '../util/platform.js';
16
15
  import { isDoordashCommand, updateDoordashProgress } from './doordash-steps.js';
17
16
  import type { ServerMessage } from './ipc-protocol.js';
18
- import {
19
- refreshSurfacesForApp,
20
- surfaceProxyResolver,
21
- } from './session-surfaces.js';
17
+ import { refreshSurfacesForApp } from './session-surfaces.js';
22
18
  import type { ToolSetupContext } from './session-tool-setup.js';
23
19
 
24
20
  // ── Types ────────────────────────────────────────────────────────────
@@ -37,20 +33,16 @@ export type PostExecutionHook = (
37
33
 
38
34
  // ── Helpers ──────────────────────────────────────────────────────────
39
35
 
40
- /** Shared logic for refreshing app surfaces, broadcasting changes, and auto-opening. */
36
+ /** Shared logic for refreshing app surfaces, broadcasting changes, and triggering auto-deploy. */
41
37
  function handleAppChange(
42
38
  ctx: ToolSetupContext,
43
39
  appId: string,
44
40
  broadcastToAllClients: ((msg: ServerMessage) => void) | undefined,
45
41
  opts?: { fileChange?: boolean; status?: string },
46
42
  ): void {
47
- const refreshed = refreshSurfacesForApp(ctx, appId, opts);
43
+ refreshSurfacesForApp(ctx, appId, opts);
48
44
  broadcastToAllClients?.({ type: 'app_files_changed', appId });
49
45
  void updatePublishedAppDeployment(appId);
50
- if (!refreshed && !ctx.hasNoClient && !ctx.headlessLock) {
51
- const resolver = (tn: string, pi: Record<string, unknown>) => surfaceProxyResolver(ctx, tn, pi);
52
- void openAppViaSurface(appId, resolver);
53
- }
54
46
  }
55
47
 
56
48
  // ── Registry ─────────────────────────────────────────────────────────
@@ -82,7 +74,6 @@ registerHook('app_create', (_name, _input, result, { ctx, broadcastToAllClients
82
74
  });
83
75
 
84
76
  // Auto-refresh workspace surfaces when a persisted app is updated.
85
- // If no surface is currently showing the app, auto-open it.
86
77
  registerHook('app_update', (_name, input, _result, { ctx, broadcastToAllClients }) => {
87
78
  const appId = input.app_id as string | undefined;
88
79
  if (appId) {
@@ -109,7 +100,6 @@ registerHook(
109
100
  );
110
101
 
111
102
  // Auto-refresh workspace surfaces when app files are edited.
112
- // If no surface is currently showing the app, auto-open it.
113
103
  registerHook(
114
104
  ['app_file_edit', 'app_file_write'],
115
105
  (_name, input, _result, { ctx, broadcastToAllClients }) => {
package/src/mcp/client.ts CHANGED
@@ -30,15 +30,13 @@ export class McpClient {
30
30
  private transport: StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport | null = null;
31
31
  private connected = false;
32
32
  private oauthProvider: McpOAuthProvider | null = null;
33
- private quiet: boolean;
34
33
 
35
34
  get isConnected(): boolean {
36
35
  return this.connected;
37
36
  }
38
37
 
39
- constructor(serverId: string, opts?: { quiet?: boolean }) {
38
+ constructor(serverId: string) {
40
39
  this.serverId = serverId;
41
- this.quiet = opts?.quiet ?? false;
42
40
  this.client = new Client({
43
41
  name: 'vellum-assistant',
44
42
  version: '1.0.0',
@@ -61,7 +59,7 @@ export class McpClient {
61
59
  }
62
60
  }
63
61
 
64
- if (!this.quiet) console.log(`[MCP] Connecting to server "${this.serverId}"...`);
62
+ log.info({ serverId: this.serverId }, 'Connecting to MCP server');
65
63
  this.transport = this.createTransport(transportConfig);
66
64
 
67
65
  try {
@@ -82,13 +80,11 @@ export class McpClient {
82
80
  if (isAuthError) {
83
81
  // Auth-related — user can run `vellum mcp auth <name>` to authenticate.
84
82
  log.info({ serverId: this.serverId, err }, 'MCP server requires authentication');
85
- if (!this.quiet) console.log(`[MCP] Server "${this.serverId}" requires authentication. Run "vellum mcp auth ${this.serverId}" to authenticate.`);
86
83
  return;
87
84
  }
88
85
 
89
86
  // Non-auth error (DNS, TLS, timeout, etc.) — log and re-throw
90
87
  log.error({ serverId: this.serverId, err }, 'MCP server connection failed');
91
- if (!this.quiet) console.error(`[MCP] Server "${this.serverId}" connection failed: ${err instanceof Error ? err.message : err}`);
92
88
  throw err;
93
89
  }
94
90
 
@@ -96,7 +92,6 @@ export class McpClient {
96
92
  }
97
93
 
98
94
  this.connected = true;
99
- if (!this.quiet) console.log(`[MCP] Server "${this.serverId}" connected successfully`);
100
95
  log.info({ serverId: this.serverId }, 'MCP client connected');
101
96
  }
102
97
 
@@ -148,11 +148,25 @@ export function getSecureKey(account: string): string | undefined {
148
148
  * Returns `true` on success, `false` on failure.
149
149
  */
150
150
  export function setSecureKey(account: string, value: string): boolean {
151
- return withKeychainFallback(
151
+ const result = withKeychainFallback(
152
152
  () => keychain.setKey(account, value),
153
153
  () => encryptedStore.setKey(account, value),
154
154
  false,
155
155
  );
156
+ // When writing to the encrypted store after a keychain downgrade, clean up
157
+ // any stale keychain entry so the gateway's credential-reader (which tries
158
+ // keychain first) does not read an outdated value.
159
+ if (result && downgradedFromKeychain && getBackend() === "encrypted") {
160
+ keychainMissCache.delete(account);
161
+ try {
162
+ // Only attempt deletion if the key actually exists in keychain to
163
+ // avoid spawning a subprocess on every write.
164
+ if (keychain.getKey(account) !== undefined) {
165
+ keychain.deleteKey(account);
166
+ }
167
+ } catch { /* best-effort */ }
168
+ }
169
+ return result;
156
170
  }
157
171
 
158
172
  /**
@@ -278,7 +292,22 @@ export async function setSecureKeyAsync(
278
292
  value: string,
279
293
  ): Promise<boolean> {
280
294
  const backend = await getBackendAsync();
281
- if (backend === "encrypted") return encryptedStore.setKey(account, value);
295
+ if (backend === "encrypted") {
296
+ const result = encryptedStore.setKey(account, value);
297
+ // Clean up stale keychain entry (mirrors setSecureKey logic).
298
+ if (result && downgradedFromKeychain) {
299
+ keychainMissCache.delete(account);
300
+ try {
301
+ // Only attempt deletion if the key actually exists in keychain to
302
+ // avoid spawning a subprocess on every write.
303
+ const exists = await keychain.getKeyAsync(account);
304
+ if (exists !== undefined) {
305
+ await keychain.deleteKeyAsync(account);
306
+ }
307
+ } catch { /* best-effort */ }
308
+ }
309
+ return result;
310
+ }
282
311
  if (backend !== "keychain") return false;
283
312
 
284
313
  const result = await keychain.setKeyAsync(account, value);
@@ -288,7 +317,18 @@ export async function setSecureKeyAsync(
288
317
  );
289
318
  resolvedBackend = "encrypted";
290
319
  downgradedFromKeychain = true;
291
- return encryptedStore.setKey(account, value);
320
+ const fallbackResult = encryptedStore.setKey(account, value);
321
+ // Clean up stale keychain entry after runtime downgrade
322
+ if (fallbackResult) {
323
+ keychainMissCache.delete(account);
324
+ try {
325
+ const exists = await keychain.getKeyAsync(account);
326
+ if (exists !== undefined) {
327
+ await keychain.deleteKeyAsync(account);
328
+ }
329
+ } catch { /* best-effort */ }
330
+ }
331
+ return fallbackResult;
292
332
  }
293
333
  return result;
294
334
  }
@@ -43,6 +43,11 @@ const appOpenTool: Tool = {
43
43
  type: 'string',
44
44
  description: 'The ID of the app to open',
45
45
  },
46
+ open_mode: {
47
+ type: 'string',
48
+ enum: ['preview', 'workspace'],
49
+ description: "Display mode. 'preview' shows an inline preview card in chat. 'workspace' opens the full app in a workspace panel. Defaults to 'workspace'.",
50
+ },
46
51
  },
47
52
  required: ['app_id'],
48
53
  },
@@ -11,7 +11,6 @@
11
11
  import { setHomeBaseAppLink } from '../../home-base/app-link-store.js';
12
12
  import type { AppDefinition } from '../../memory/app-store.js';
13
13
  import type { EditEngineResult } from '../../memory/app-store.js';
14
- import { openAppViaSurface } from './open-proxy.js';
15
14
 
16
15
  // ---------------------------------------------------------------------------
17
16
  // Shared result type
@@ -123,33 +122,30 @@ export async function executeAppCreate(
123
122
  setHomeBaseAppLink(app.id, 'personalized');
124
123
  }
125
124
 
126
- // Auto-open the app via the shared open-proxy helper
125
+ // Emit the inline preview card via the proxy without opening a workspace panel.
126
+ // open_mode: "preview" signals to the client that this should be shown inline only.
127
127
  if (autoOpen && proxyToolResolver) {
128
128
  const createPreview = { ...(preview ?? {}), context: 'app_create' as const };
129
- const extraInput = { preview: createPreview };
130
- const openResultText = await openAppViaSurface(app.id, proxyToolResolver, extraInput);
131
-
132
- // Determine whether the open succeeded by checking for the fallback text
133
- const opened = openResultText !== 'Failed to auto-open app. Use app_open to open it manually.';
134
- if (opened) {
129
+ const extraInput = { preview: createPreview, open_mode: 'preview' };
130
+ try {
131
+ const openResult = await proxyToolResolver('app_open', { app_id: app.id, ...extraInput });
132
+ if (openResult.isError) {
133
+ return {
134
+ content: JSON.stringify({ ...app, auto_opened: false, auto_open_error: openResult.content }),
135
+ isError: false,
136
+ };
137
+ }
138
+ return {
139
+ content: JSON.stringify({ ...app, auto_opened: true, open_result: openResult.content }),
140
+ isError: false,
141
+ };
142
+ } catch {
143
+ // Preview emission failure is non-fatal — the app was created successfully.
135
144
  return {
136
- content: JSON.stringify({
137
- ...app,
138
- auto_opened: true,
139
- open_result: openResultText,
140
- }),
145
+ content: JSON.stringify({ ...app, auto_opened: false, auto_open_error: 'Failed to auto-open app. Use app_open to open it manually.' }),
141
146
  isError: false,
142
147
  };
143
148
  }
144
-
145
- return {
146
- content: JSON.stringify({
147
- ...app,
148
- auto_opened: false,
149
- auto_open_error: openResultText,
150
- }),
151
- isError: false,
152
- };
153
149
  }
154
150
 
155
151
  return { content: JSON.stringify(app), isError: false };
@@ -6,6 +6,7 @@
6
6
  * Shared by the sandbox bash tool and skill sandbox runner.
7
7
  */
8
8
  import { getGatewayInternalBaseUrl, getIngressPublicBaseUrl } from '../../config/env.js';
9
+ import { isSigningKeyInitialized, mintEdgeRelayToken } from '../../runtime/auth/token-service.js';
9
10
 
10
11
  const SAFE_ENV_VARS = [
11
12
  'PATH',
@@ -42,5 +43,11 @@ export function buildSanitizedEnv(): Record<string, string> {
42
43
  // back to the internal base so commands remain functional in local-only mode.
43
44
  const publicGatewayBase = getIngressPublicBaseUrl()?.replace(/\/+$/, '');
44
45
  env.GATEWAY_BASE_URL = publicGatewayBase || internalGatewayBase;
46
+ // Mint a short-lived JWT for gateway authentication when the signing key
47
+ // is available (daemon context). CLI-only contexts without daemon startup
48
+ // will not have the key initialized — gracefully skip.
49
+ if (isSigningKeyInitialized()) {
50
+ env.GATEWAY_AUTH_TOKEN = mintEdgeRelayToken();
51
+ }
45
52
  return env;
46
53
  }
@@ -1,195 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
-
3
- import {
4
- classifyResponseTierAsync,
5
- classifyResponseTierDetailed,
6
- resolveWithHint,
7
- type SessionTierHint,
8
- type TierClassification,
9
- } from "../daemon/response-tier.js";
10
-
11
- // ── classifyResponseTierDetailed ──────────────────────────────────────
12
-
13
- describe("classifyResponseTierDetailed", () => {
14
- describe("high confidence → high tier", () => {
15
- test("long messages (>500 chars)", () => {
16
- const result = classifyResponseTierDetailed("x".repeat(501), 0);
17
- expect(result.tier).toBe("high");
18
- expect(result.confidence).toBe("high");
19
- });
20
-
21
- test("code fences", () => {
22
- const result = classifyResponseTierDetailed(
23
- "Here is some code:\n```\nconst x = 1;\n```",
24
- 0,
25
- );
26
- expect(result.tier).toBe("high");
27
- expect(result.confidence).toBe("high");
28
- });
29
-
30
- test("file paths", () => {
31
- const result = classifyResponseTierDetailed("Look at ./src/index.ts", 0);
32
- expect(result.tier).toBe("high");
33
- expect(result.confidence).toBe("high");
34
- });
35
-
36
- test("multi-paragraph", () => {
37
- const result = classifyResponseTierDetailed(
38
- "First paragraph.\n\nSecond paragraph.",
39
- 0,
40
- );
41
- expect(result.tier).toBe("high");
42
- expect(result.confidence).toBe("high");
43
- });
44
-
45
- test("build keyword imperatives", () => {
46
- const result = classifyResponseTierDetailed(
47
- "Build a REST API for user management",
48
- 0,
49
- );
50
- expect(result.tier).toBe("high");
51
- expect(result.confidence).toBe("high");
52
- });
53
- });
54
-
55
- describe("high confidence → low tier", () => {
56
- test("pure greetings under 40 chars", () => {
57
- const result = classifyResponseTierDetailed("hey", 0);
58
- expect(result.tier).toBe("low");
59
- expect(result.confidence).toBe("high");
60
- });
61
-
62
- test("short messages without build keywords", () => {
63
- const result = classifyResponseTierDetailed("sounds good", 0);
64
- expect(result.tier).toBe("low");
65
- expect(result.confidence).toBe("high");
66
- });
67
- });
68
-
69
- describe("low confidence → medium tier", () => {
70
- test("questions with build keywords fall to medium/low-confidence", () => {
71
- const result = classifyResponseTierDetailed(
72
- "how do I build authentication?",
73
- 0,
74
- );
75
- expect(result.tier).toBe("medium");
76
- expect(result.confidence).toBe("low");
77
- });
78
-
79
- test("ambiguous medium-length message", () => {
80
- const result = classifyResponseTierDetailed(
81
- "what do you think about the current approach to handling errors in the codebase?",
82
- 0,
83
- );
84
- expect(result.tier).toBe("medium");
85
- expect(result.confidence).toBe("low");
86
- });
87
- });
88
- });
89
-
90
- // ── resolveWithHint ───────────────────────────────────────────────────
91
-
92
- describe("resolveWithHint", () => {
93
- const lowConfMedium: TierClassification = {
94
- tier: "medium",
95
- reason: "default",
96
- confidence: "low",
97
- };
98
- const highConfLow: TierClassification = {
99
- tier: "low",
100
- reason: "short_no_keywords",
101
- confidence: "high",
102
- };
103
- const highConfHigh: TierClassification = {
104
- tier: "high",
105
- reason: "build_keyword",
106
- confidence: "high",
107
- };
108
-
109
- test("high confidence: ignores hint that would downgrade", () => {
110
- const hint: SessionTierHint = {
111
- tier: "low",
112
- turn: 5,
113
- timestamp: Date.now(),
114
- };
115
- expect(resolveWithHint(highConfHigh, hint, 6)).toBe("high");
116
- });
117
-
118
- test("high confidence: upgrades when hint is higher", () => {
119
- const hint: SessionTierHint = {
120
- tier: "medium",
121
- turn: 5,
122
- timestamp: Date.now(),
123
- };
124
- expect(resolveWithHint(highConfLow, hint, 6)).toBe("medium");
125
- });
126
-
127
- test("high confidence: upgrades to high when hint is high", () => {
128
- const hint: SessionTierHint = {
129
- tier: "high",
130
- turn: 5,
131
- timestamp: Date.now(),
132
- };
133
- expect(resolveWithHint(highConfLow, hint, 6)).toBe("high");
134
- });
135
-
136
- test("returns regex tier when no hint available", () => {
137
- expect(resolveWithHint(lowConfMedium, null, 0)).toBe("medium");
138
- });
139
-
140
- test("defers to hint when confidence is low and hint is fresh", () => {
141
- const hint: SessionTierHint = {
142
- tier: "high",
143
- turn: 5,
144
- timestamp: Date.now(),
145
- };
146
- expect(resolveWithHint(lowConfMedium, hint, 6)).toBe("high");
147
- });
148
-
149
- test("ignores stale hint (too many turns old)", () => {
150
- const hint: SessionTierHint = {
151
- tier: "high",
152
- turn: 0,
153
- timestamp: Date.now(),
154
- };
155
- // 5 turns later exceeds HINT_MAX_TURN_AGE of 4
156
- expect(resolveWithHint(lowConfMedium, hint, 5)).toBe("medium");
157
- });
158
-
159
- test("ignores stale hint (too old by time)", () => {
160
- const fiveMinutesAgo = Date.now() - 5 * 60 * 1000 - 1;
161
- const hint: SessionTierHint = {
162
- tier: "high",
163
- turn: 3,
164
- timestamp: fiveMinutesAgo,
165
- };
166
- expect(resolveWithHint(lowConfMedium, hint, 4)).toBe("medium");
167
- });
168
-
169
- test("uses hint at exact boundary (4 turns, within time)", () => {
170
- const hint: SessionTierHint = {
171
- tier: "high",
172
- turn: 1,
173
- timestamp: Date.now(),
174
- };
175
- // 5 - 1 = 4, which is not > 4, so hint is still valid
176
- expect(resolveWithHint(lowConfMedium, hint, 5)).toBe("high");
177
- });
178
- });
179
-
180
- // ── classifyResponseTierAsync ─────────────────────────────────────────
181
-
182
- describe("classifyResponseTierAsync", () => {
183
- test("returns null when no provider is available", async () => {
184
- // getConfiguredProvider returns null when no API key is set
185
- // We can't easily mock it here, but we can verify the function handles it
186
- const result = await classifyResponseTierAsync(["hello"]);
187
- // In test environment without a configured provider, should return null
188
- expect(
189
- result === undefined ||
190
- result === "low" ||
191
- result === "medium" ||
192
- result === "high",
193
- ).toBe(true);
194
- });
195
- });