@vellumai/assistant 0.4.11 → 0.4.13

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 (111) hide show
  1. package/ARCHITECTURE.md +401 -385
  2. package/package.json +1 -1
  3. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +75 -61
  4. package/src/__tests__/registry.test.ts +235 -187
  5. package/src/__tests__/secure-keys.test.ts +27 -0
  6. package/src/__tests__/session-agent-loop.test.ts +521 -256
  7. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -0
  8. package/src/__tests__/session-tool-setup-app-refresh.test.ts +1 -0
  9. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -0
  10. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -0
  11. package/src/__tests__/skills.test.ts +334 -276
  12. package/src/__tests__/slack-skill.test.ts +124 -0
  13. package/src/__tests__/starter-task-flow.test.ts +7 -17
  14. package/src/agent/loop.ts +10 -3
  15. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +449 -0
  16. package/src/config/bundled-skills/doordash/SKILL.md +171 -0
  17. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +203 -0
  18. package/src/config/bundled-skills/doordash/__tests__/doordash-session.test.ts +164 -0
  19. package/src/config/bundled-skills/doordash/doordash-cli.ts +1193 -0
  20. package/src/config/bundled-skills/doordash/doordash-entry.ts +22 -0
  21. package/src/config/bundled-skills/doordash/lib/cart-queries.ts +787 -0
  22. package/src/config/bundled-skills/doordash/lib/client.ts +1071 -0
  23. package/src/config/bundled-skills/doordash/lib/order-queries.ts +85 -0
  24. package/src/config/bundled-skills/doordash/lib/queries.ts +28 -0
  25. package/src/config/bundled-skills/doordash/lib/query-extractor.ts +94 -0
  26. package/src/config/bundled-skills/doordash/lib/search-queries.ts +203 -0
  27. package/src/config/bundled-skills/doordash/lib/session.ts +93 -0
  28. package/src/config/bundled-skills/doordash/lib/shared/errors.ts +61 -0
  29. package/src/config/bundled-skills/doordash/lib/shared/ipc.ts +32 -0
  30. package/src/config/bundled-skills/doordash/lib/shared/network-recorder.ts +380 -0
  31. package/src/config/bundled-skills/doordash/lib/shared/platform.ts +35 -0
  32. package/src/config/bundled-skills/doordash/lib/shared/recording-store.ts +43 -0
  33. package/src/config/bundled-skills/doordash/lib/shared/recording-types.ts +49 -0
  34. package/src/config/bundled-skills/doordash/lib/shared/truncate.ts +6 -0
  35. package/src/config/bundled-skills/doordash/lib/store-queries.ts +246 -0
  36. package/src/config/bundled-skills/doordash/lib/types.ts +367 -0
  37. package/src/config/bundled-skills/google-calendar/SKILL.md +4 -5
  38. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +41 -41
  39. package/src/config/bundled-skills/messaging/SKILL.md +59 -42
  40. package/src/config/bundled-skills/messaging/TOOLS.json +14 -92
  41. package/src/config/bundled-skills/messaging/tools/gmail-archive-by-query.ts +5 -1
  42. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +11 -2
  43. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +8 -1
  44. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +12 -4
  45. package/src/config/bundled-skills/messaging/tools/gmail-unsubscribe.ts +5 -1
  46. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +5 -1
  47. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +5 -2
  48. package/src/config/bundled-skills/notion/SKILL.md +240 -0
  49. package/src/config/bundled-skills/notion-oauth-setup/SKILL.md +127 -0
  50. package/src/config/bundled-skills/oauth-setup/SKILL.md +144 -0
  51. package/src/config/bundled-skills/phone-calls/SKILL.md +76 -45
  52. package/src/config/bundled-skills/skills-catalog/SKILL.md +32 -29
  53. package/src/config/bundled-skills/slack/SKILL.md +49 -0
  54. package/src/config/bundled-skills/slack/TOOLS.json +167 -0
  55. package/src/config/bundled-skills/slack/tools/shared.ts +23 -0
  56. package/src/config/bundled-skills/{messaging → slack}/tools/slack-add-reaction.ts +2 -5
  57. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +33 -0
  58. package/src/config/bundled-skills/slack/tools/slack-configure-channels.ts +75 -0
  59. package/src/config/bundled-skills/{messaging → slack}/tools/slack-delete-message.ts +2 -5
  60. package/src/config/bundled-skills/{messaging → slack}/tools/slack-leave-channel.ts +2 -5
  61. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +193 -0
  62. package/src/config/{vellum-skills → bundled-skills}/sms-setup/SKILL.md +29 -22
  63. package/src/config/{vellum-skills → bundled-skills}/telegram-setup/SKILL.md +17 -14
  64. package/src/config/{vellum-skills → bundled-skills}/twilio-setup/SKILL.md +20 -5
  65. package/src/config/bundled-tool-registry.ts +292 -267
  66. package/src/config/schema.ts +1 -1
  67. package/src/daemon/handlers/skills.ts +334 -234
  68. package/src/daemon/ipc-contract/messages.ts +2 -0
  69. package/src/daemon/ipc-contract/surfaces.ts +2 -0
  70. package/src/daemon/lifecycle.ts +358 -221
  71. package/src/daemon/response-tier.ts +2 -0
  72. package/src/daemon/server.ts +453 -193
  73. package/src/daemon/session-agent-loop-handlers.ts +43 -2
  74. package/src/daemon/session-agent-loop.ts +3 -0
  75. package/src/daemon/session-lifecycle.ts +3 -0
  76. package/src/daemon/session-process.ts +1 -0
  77. package/src/daemon/session-surfaces.ts +22 -20
  78. package/src/daemon/session-tool-setup.ts +1 -0
  79. package/src/daemon/session.ts +5 -2
  80. package/src/messaging/outreach-classifier.ts +12 -5
  81. package/src/messaging/provider-types.ts +5 -0
  82. package/src/messaging/provider.ts +1 -1
  83. package/src/messaging/providers/gmail/adapter.ts +11 -5
  84. package/src/messaging/providers/gmail/client.ts +2 -0
  85. package/src/messaging/providers/slack/adapter.ts +1 -0
  86. package/src/messaging/providers/slack/client.ts +8 -0
  87. package/src/messaging/providers/slack/types.ts +5 -0
  88. package/src/runtime/http-errors.ts +33 -20
  89. package/src/runtime/http-server.ts +706 -291
  90. package/src/runtime/http-types.ts +26 -16
  91. package/src/runtime/routes/secret-routes.ts +57 -2
  92. package/src/runtime/routes/surface-action-routes.ts +66 -0
  93. package/src/runtime/routes/trust-rules-routes.ts +140 -0
  94. package/src/security/keychain-to-encrypted-migration.ts +59 -0
  95. package/src/security/secure-keys.ts +17 -0
  96. package/src/skills/frontmatter.ts +9 -7
  97. package/src/tools/apps/executors.ts +2 -1
  98. package/src/tools/tool-manifest.ts +44 -42
  99. package/src/tools/types.ts +9 -0
  100. package/src/__tests__/skill-mirror-parity.test.ts +0 -176
  101. package/src/config/vellum-skills/catalog.json +0 -63
  102. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +0 -295
  103. package/src/skills/vellum-catalog-remote.ts +0 -166
  104. package/src/tools/skills/vellum-catalog.ts +0 -168
  105. /package/src/config/{vellum-skills → bundled-skills}/chatgpt-import/SKILL.md +0 -0
  106. /package/src/config/{vellum-skills → bundled-skills}/chatgpt-import/TOOLS.json +0 -0
  107. /package/src/config/{vellum-skills → bundled-skills}/deploy-fullstack-vercel/SKILL.md +0 -0
  108. /package/src/config/{vellum-skills → bundled-skills}/document-writer/SKILL.md +0 -0
  109. /package/src/config/{vellum-skills → bundled-skills}/guardian-verify-setup/SKILL.md +0 -0
  110. /package/src/config/{vellum-skills → bundled-skills}/slack-oauth-setup/SKILL.md +0 -0
  111. /package/src/config/{vellum-skills → bundled-skills}/trusted-contacts/SKILL.md +0 -0
@@ -53,6 +53,8 @@ export interface EventHandlerState {
53
53
  firstTextDeltaEmitted: boolean;
54
54
  /** Tracks whether a thinking delta has been emitted this turn for activity state transitions. */
55
55
  firstThinkingDeltaEmitted: boolean;
56
+ /** Name of the last completed tool, used to generate contextual statusText. */
57
+ lastCompletedToolName: string | undefined;
56
58
  }
57
59
 
58
60
  /** Immutable context shared across event handlers within a single agent loop run. */
@@ -92,6 +94,7 @@ export function createEventHandlerState(): EventHandlerState {
92
94
  currentTurnToolNames: [],
93
95
  firstTextDeltaEmitted: false,
94
96
  firstThinkingDeltaEmitted: false,
97
+ lastCompletedToolName: undefined,
95
98
  };
96
99
  }
97
100
 
@@ -130,6 +133,29 @@ function truncateForIpc(value: string, maxChars: number, suffix: string): string
130
133
  return value.slice(0, maxChars - suffix.length) + suffix;
131
134
  }
132
135
 
136
+ // ── Friendly Tool Names ──────────────────────────────────────────────
137
+
138
+ const TOOL_FRIENDLY_NAMES: Record<string, string> = {
139
+ bash: 'command',
140
+ web_search: 'web search',
141
+ web_fetch: 'web fetch',
142
+ file_read: 'file read',
143
+ file_write: 'file write',
144
+ file_edit: 'file edit',
145
+ browser_navigate: 'browser',
146
+ browser_click: 'browser',
147
+ browser_type: 'browser',
148
+ browser_screenshot: 'browser',
149
+ browser_scroll: 'browser',
150
+ browser_wait: 'browser',
151
+ app_create: 'app',
152
+ app_update: 'app',
153
+ };
154
+
155
+ function friendlyToolName(name: string): string {
156
+ return TOOL_FRIENDLY_NAMES[name] ?? name.replace(/_/g, ' ');
157
+ }
158
+
133
159
  // ── Individual Handlers ──────────────────────────────────────────────
134
160
 
135
161
  export function handleTextDelta(
@@ -158,7 +184,18 @@ export function handleThinkingDelta(
158
184
  ): void {
159
185
  if (!state.firstThinkingDeltaEmitted) {
160
186
  state.firstThinkingDeltaEmitted = true;
161
- deps.ctx.emitActivityState('thinking', 'thinking_delta', 'assistant_turn', deps.reqId);
187
+ const lastToolName = state.lastCompletedToolName;
188
+ // Only emit an activity state when a tool just completed, so we can
189
+ // show "Processing <tool> results". When no tool has completed yet
190
+ // (e.g. right after confirmation_resolved), skip the emission entirely
191
+ // so the client preserves its current status text (e.g. "Resuming
192
+ // after approval"). Even omitting statusText from the message would
193
+ // cause the client to clear it, since the client overwrites
194
+ // assistantStatusText for every assistant_activity_state event.
195
+ if (lastToolName) {
196
+ const statusText = `Processing ${friendlyToolName(lastToolName)} results`;
197
+ deps.ctx.emitActivityState('thinking', 'thinking_delta', 'assistant_turn', deps.reqId, statusText);
198
+ }
162
199
  }
163
200
  if (!deps.ctx.streamThinking) return;
164
201
  emitLlmCallStartedIfNeeded(state, deps);
@@ -172,7 +209,8 @@ export function handleToolUse(
172
209
  ): void {
173
210
  state.toolUseIdToName.set(event.id, event.name);
174
211
  state.currentTurnToolNames.push(event.name);
175
- deps.ctx.emitActivityState('tool_running', 'tool_use_start', 'assistant_turn', deps.reqId);
212
+ const statusText = `Running ${friendlyToolName(event.name)}`;
213
+ deps.ctx.emitActivityState('tool_running', 'tool_use_start', 'assistant_turn', deps.reqId, statusText);
176
214
  deps.onEvent({ type: 'tool_use_start', toolName: event.name, input: event.input, sessionId: deps.ctx.conversationId });
177
215
  }
178
216
 
@@ -274,6 +312,9 @@ export function handleToolResult(
274
312
  }
275
313
  }
276
314
 
315
+ // Track last completed tool for contextual statusText on next thinking phase
316
+ state.lastCompletedToolName = state.toolUseIdToName.get(event.toolUseId);
317
+
277
318
  // Reset so that the next LLM exchange (think → stream) after this tool
278
319
  // call re-emits the activity state transitions.
279
320
  state.firstTextDeltaEmitted = false;
@@ -98,6 +98,7 @@ export interface AgentLoopSessionContext {
98
98
  currentPage?: string;
99
99
  readonly surfaceState: Map<string, { surfaceType: SurfaceType; data: SurfaceData; title?: string }>;
100
100
  pendingSurfaceActions: Map<string, { surfaceType: SurfaceType }>;
101
+ surfaceActionRequestIds: Set<string>;
101
102
  currentTurnSurfaces: Array<{ surfaceId: string; surfaceType: SurfaceType; title?: string; data: SurfaceData; actions?: Array<{ id: string; label: string; style?: string }>; display?: string }>;
102
103
 
103
104
  workingDir: string;
@@ -135,6 +136,7 @@ export interface AgentLoopSessionContext {
135
136
  reason: 'message_dequeued' | 'thinking_delta' | 'first_text_delta' | 'tool_use_start' | 'confirmation_requested' | 'confirmation_resolved' | 'message_complete' | 'generation_cancelled' | 'error_terminal',
136
137
  anchor?: 'assistant_turn' | 'user_turn' | 'global',
137
138
  requestId?: string,
139
+ statusText?: string,
138
140
  ): void;
139
141
  emitConfirmationStateChanged(params: import('./ipc-contract/messages.js').ConfirmationStateChanged extends { type: infer _ } ? Omit<import('./ipc-contract/messages.js').ConfirmationStateChanged, 'type'> : never): void;
140
142
 
@@ -839,6 +841,7 @@ export async function runAgentLoopImpl(
839
841
 
840
842
  ctx.abortController = null;
841
843
  ctx.processing = false;
844
+ ctx.surfaceActionRequestIds.delete(ctx.currentRequestId ?? '');
842
845
  ctx.currentRequestId = undefined;
843
846
  ctx.currentActiveSurfaceId = undefined;
844
847
  ctx.allowedToolNames = undefined;
@@ -70,6 +70,7 @@ export interface AbortContext {
70
70
  prompter: PermissionPrompter;
71
71
  secretPrompter: SecretPrompter;
72
72
  pendingSurfaceActions: Map<string, { surfaceType: SurfaceType }>;
73
+ surfaceActionRequestIds: Set<string>;
73
74
  surfaceState: Map<string, { surfaceType: SurfaceType; data: SurfaceData; title?: string }>;
74
75
  readonly queue: MessageQueue;
75
76
  }
@@ -159,6 +160,7 @@ export function abortSession(ctx: AbortContext): void {
159
160
  ctx.prompter.dispose();
160
161
  ctx.secretPrompter.dispose();
161
162
  ctx.pendingSurfaceActions.clear();
163
+ ctx.surfaceActionRequestIds.clear();
162
164
  ctx.surfaceState.clear();
163
165
  unregisterWatchNotifiers(ctx.conversationId);
164
166
  for (const queued of ctx.queue) {
@@ -186,6 +188,7 @@ export function disposeSession(ctx: DisposeContext): void {
186
188
  ctx.surfaceUndoStacks.clear();
187
189
  ctx.currentTurnSurfaces = [];
188
190
  ctx.pendingSurfaceActions.clear();
191
+ ctx.surfaceActionRequestIds.clear();
189
192
  ctx.surfaceState.clear();
190
193
  ctx.lastSurfaceAction.clear();
191
194
  ctx.workspaceTopLevelContext = null;
@@ -92,6 +92,7 @@ export interface ProcessSessionContext {
92
92
  reason: 'message_dequeued' | 'thinking_delta' | 'first_text_delta' | 'tool_use_start' | 'confirmation_requested' | 'confirmation_resolved' | 'message_complete' | 'generation_cancelled' | 'error_terminal',
93
93
  anchor?: 'assistant_turn' | 'user_turn' | 'global',
94
94
  requestId?: string,
95
+ statusText?: string,
95
96
  ): void;
96
97
  }
97
98
 
@@ -4,7 +4,7 @@ import {
4
4
  findSeededHomeBaseApp,
5
5
  getPrebuiltHomeBasePreview,
6
6
  } from '../home-base/prebuilt/seed.js';
7
- import { getApp, updateApp } from '../memory/app-store.js';
7
+ import { getApp, getAppPreview, updateApp } from '../memory/app-store.js';
8
8
  import type { ToolExecutionResult } from '../tools/types.js';
9
9
  import { getLogger } from '../util/logger.js';
10
10
  import { isPlainObject } from '../util/object.js';
@@ -133,6 +133,8 @@ export interface SurfaceSessionContext {
133
133
  lastSurfaceAction: Map<string, { actionId: string; data?: Record<string, unknown> }>;
134
134
  surfaceState: Map<string, { surfaceType: SurfaceType; data: SurfaceData; title?: string }>;
135
135
  surfaceUndoStacks: Map<string, string[]>;
136
+ /** Request IDs that originated from surface action button clicks (not regular user messages). */
137
+ surfaceActionRequestIds: Set<string>;
136
138
  currentTurnSurfaces: Array<{
137
139
  surfaceId: string;
138
140
  surfaceType: SurfaceType;
@@ -393,24 +395,19 @@ export function handleSurfaceAction(ctx: SurfaceSessionContext, surfaceId: strin
393
395
  ? data.prompt.trim()
394
396
  : '';
395
397
 
396
- // Build a human-readable summary for confirmation surfaces so the LLM
397
- // clearly understands the user's decision instead of parsing raw JSON.
398
- let fallbackContent: string;
399
- if (pending.surfaceType === 'confirmation') {
400
- const summary = buildCompletionSummary('confirmation', actionId, data);
401
- fallbackContent = `[User action on confirmation surface: ${summary}]`;
402
- } else {
403
- fallbackContent = JSON.stringify({
404
- surfaceAction: true,
405
- surfaceId,
406
- surfaceType: pending.surfaceType,
407
- actionId,
408
- data: data ?? {},
409
- });
398
+ // Build a human-readable summary so the LLM clearly understands the
399
+ // user's decision instead of parsing raw JSON.
400
+ const summary = buildCompletionSummary(pending.surfaceType, actionId, data);
401
+ let fallbackContent = `[User action on ${pending.surfaceType} surface: ${summary}]`;
402
+ // Append structured data so the LLM has access to IDs/values it needs
403
+ // to act on (e.g. selectedIds for archiving).
404
+ if (data && Object.keys(data).length > 0) {
405
+ fallbackContent += `\n\nAction data: ${JSON.stringify(data)}`;
410
406
  }
411
407
  const content = prompt || fallbackContent;
412
408
 
413
409
  const requestId = uuid();
410
+ ctx.surfaceActionRequestIds.add(requestId);
414
411
  const onEvent = (msg: ServerMessage) => ctx.sendToClient(msg);
415
412
 
416
413
  // Echo the user's prompt to the client so it appears in the chat UI
@@ -534,13 +531,15 @@ export function buildCompletionSummary(surfaceType: string | undefined, actionId
534
531
  }
535
532
  if (surfaceType === 'list' && data) {
536
533
  const selectedIds = data.selectedIds as string[] | undefined;
537
- if (selectedIds?.length === 1) return `Selected: ${selectedIds[0]}`;
538
- if (selectedIds?.length) return `Selected ${selectedIds.length} items`;
534
+ const actionSuffix = actionId ? ` (action: ${actionId})` : '';
535
+ if (selectedIds?.length === 1) return `Selected: ${selectedIds[0]}${actionSuffix}`;
536
+ if (selectedIds?.length) return `Selected ${selectedIds.length} items${actionSuffix}`;
539
537
  }
540
538
  if (surfaceType === 'table' && data) {
541
539
  const selectedIds = data.selectedIds as string[] | undefined;
542
- if (selectedIds?.length === 1) return `Selected 1 row`;
543
- if (selectedIds?.length) return `Selected ${selectedIds.length} rows`;
540
+ const actionSuffix = actionId ? ` (action: ${actionId})` : '';
541
+ if (selectedIds?.length === 1) return `Selected 1 row${actionSuffix}`;
542
+ if (selectedIds?.length) return `Selected ${selectedIds.length} rows${actionSuffix}`;
544
543
  }
545
544
  return actionId.charAt(0).toUpperCase() + actionId.slice(1);
546
545
  }
@@ -608,6 +607,7 @@ export async function surfaceProxyResolver(
608
607
  message: 'File upload dialog displayed and the user can see it. The uploaded file data will arrive as a follow-up message. Do not output any waiting message — just stop here.',
609
608
  }),
610
609
  isError: false,
610
+ yieldToUser: true,
611
611
  };
612
612
  }
613
613
 
@@ -668,6 +668,7 @@ export async function surfaceProxyResolver(
668
668
  message: 'Surface displayed and the user can see it. Their response will arrive as a follow-up message. Do not output any waiting message — just stop here.',
669
669
  }),
670
670
  isError: false,
671
+ yieldToUser: true,
671
672
  };
672
673
  }
673
674
  return { content: JSON.stringify({ surfaceId }), isError: false };
@@ -772,11 +773,12 @@ export async function surfaceProxyResolver(
772
773
  // un-clickable fallback chip) after session restart.
773
774
  : { title: app.name, subtitle: app.description };
774
775
 
776
+ const storedPreview = getAppPreview(app.id);
775
777
  const surfaceData: DynamicPageSurfaceData = {
776
778
  html: app.htmlDefinition,
777
779
  appId: app.id,
778
780
  appType: app.appType,
779
- preview: preview ?? defaultPreview,
781
+ preview: { ...defaultPreview, ...preview, ...(storedPreview ? { previewImage: storedPreview } : {}) },
780
782
  };
781
783
  const surfaceId = uuid();
782
784
  ctx.surfaceState.set(surfaceId, {
@@ -113,6 +113,7 @@ export function createToolExecutor(
113
113
  guardianTrustClass: ctx.guardianContext?.trustClass ?? 'guardian',
114
114
  executionChannel: ctx.guardianContext?.sourceChannel,
115
115
  callSessionId: ctx.callSessionId,
116
+ triggeredBySurfaceAction: ctx.surfaceActionRequestIds?.has(ctx.currentRequestId ?? '') ?? false,
116
117
  requesterExternalUserId: ctx.guardianContext?.requesterExternalUserId,
117
118
  requesterChatId: ctx.guardianContext?.requesterChatId,
118
119
  onOutput,
@@ -146,6 +146,7 @@ export class Session {
146
146
  /** @internal */ voiceCallControlPrompt?: string;
147
147
  /** @internal */ assistantId?: string;
148
148
  /** @internal */ commandIntent?: { type: string; payload?: string; languageCode?: string };
149
+ /** @internal */ surfaceActionRequestIds = new Set<string>();
149
150
  /** @internal */ pendingSurfaceActions = new Map<string, { surfaceType: SurfaceType }>();
150
151
  /** @internal */ lastSurfaceAction = new Map<string, { actionId: string; data?: Record<string, unknown> }>();
151
152
  /** @internal */ surfaceState = new Map<string, { surfaceType: SurfaceType; data: SurfaceData; title?: string }>();
@@ -205,7 +206,7 @@ export class Session {
205
206
  if (state === 'pending') {
206
207
  this.emitActivityState('awaiting_confirmation', 'confirmation_requested', 'assistant_turn');
207
208
  } else if (state === 'timed_out') {
208
- this.emitActivityState('thinking', 'confirmation_resolved', 'assistant_turn');
209
+ this.emitActivityState('thinking', 'confirmation_resolved', 'assistant_turn', undefined, 'Resuming after timeout');
209
210
  }
210
211
  });
211
212
  this.secretPrompter = new SecretPrompter(sendToClient);
@@ -520,7 +521,7 @@ export class Session {
520
521
  ...(emissionContext?.causedByRequestId ? { causedByRequestId: emissionContext.causedByRequestId } : {}),
521
522
  ...(emissionContext?.decisionText ? { decisionText: emissionContext.decisionText } : {}),
522
523
  });
523
- this.emitActivityState('thinking', 'confirmation_resolved', 'assistant_turn');
524
+ this.emitActivityState('thinking', 'confirmation_resolved', 'assistant_turn', undefined, 'Resuming after approval');
524
525
  }
525
526
 
526
527
  handleSecretResponse(requestId: string, value?: string, delivery?: 'store' | 'transient_send'): void {
@@ -540,6 +541,7 @@ export class Session {
540
541
  reason: AssistantActivityState['reason'],
541
542
  anchor: AssistantActivityState['anchor'] = 'assistant_turn',
542
543
  requestId?: string,
544
+ statusText?: string,
543
545
  ): void {
544
546
  this.activityVersion++;
545
547
  const msg: ServerMessage = {
@@ -550,6 +552,7 @@ export class Session {
550
552
  anchor,
551
553
  requestId,
552
554
  reason,
555
+ ...(statusText ? { statusText } : {}),
553
556
  } as ServerMessage;
554
557
  this.sendToClient(msg);
555
558
  this.onStateSignal?.(msg);
@@ -12,6 +12,7 @@ const log = getLogger('outreach-classifier');
12
12
  const MODEL_INTENT = 'latency-optimized' as const;
13
13
  const TIMEOUT_MS = 30_000;
14
14
  const BATCH_SIZE = 100;
15
+ const CLASSIFY_CONCURRENCY = 3;
15
16
 
16
17
  export type OutreachType = 'sales' | 'recruiting' | 'marketing' | 'other';
17
18
 
@@ -128,12 +129,18 @@ async function classifyBatch(emails: EmailMetadata[]): Promise<OutreachClassific
128
129
  export async function classifyOutreach(emails: EmailMetadata[]): Promise<OutreachClassification[]> {
129
130
  if (emails.length === 0) return [];
130
131
 
131
- const results: OutreachClassification[] = [];
132
-
132
+ // Split into batches
133
+ const batches: EmailMetadata[][] = [];
133
134
  for (let i = 0; i < emails.length; i += BATCH_SIZE) {
134
- const batch = emails.slice(i, i + BATCH_SIZE);
135
- const batchResults = await classifyBatch(batch);
136
- results.push(...batchResults);
135
+ batches.push(emails.slice(i, i + BATCH_SIZE));
136
+ }
137
+
138
+ // Process batches with bounded concurrency
139
+ const results: OutreachClassification[] = [];
140
+ for (let i = 0; i < batches.length; i += CLASSIFY_CONCURRENCY) {
141
+ const concurrentBatches = batches.slice(i, i + CLASSIFY_CONCURRENCY);
142
+ const batchResults = await Promise.all(concurrentBatches.map((batch) => classifyBatch(batch)));
143
+ for (const r of batchResults) results.push(...r);
137
144
  }
138
145
 
139
146
  return results;
@@ -10,6 +10,7 @@ export interface Conversation {
10
10
  memberCount?: number;
11
11
  topic?: string;
12
12
  isArchived?: boolean;
13
+ isPrivate?: boolean;
13
14
  metadata?: Record<string, unknown>;
14
15
  }
15
16
 
@@ -99,6 +100,10 @@ export interface SenderDigestResult {
99
100
  senders: SenderDigestEntry[];
100
101
  totalScanned: number;
101
102
  queryUsed: string;
103
+ /** True when pagination was stopped because the scan cap was reached but more messages exist. */
104
+ truncated?: boolean;
105
+ /** Resume token for sequential scanning — present when truncated is true. */
106
+ nextPageToken?: string;
102
107
  }
103
108
 
104
109
  export interface ArchiveResult {
@@ -41,7 +41,7 @@ export interface MessagingProvider {
41
41
  markRead?(token: string, conversationId: string, messageId?: string): Promise<void>;
42
42
 
43
43
  /** Scan messages and group by sender for bulk cleanup (e.g. newsletter decluttering). */
44
- senderDigest?(token: string, query: string, options?: { maxMessages?: number; maxSenders?: number }): Promise<SenderDigestResult>;
44
+ senderDigest?(token: string, query: string, options?: { maxMessages?: number; maxSenders?: number; pageToken?: string }): Promise<SenderDigestResult>;
45
45
  /** Archive messages matching a search query. */
46
46
  archiveByQuery?(token: string, query: string): Promise<ArchiveResult>;
47
47
 
@@ -195,13 +195,14 @@ export const gmailMessagingProvider: MessagingProvider = {
195
195
  await gmail.modifyMessage(token, messageId, { removeLabelIds: ['UNREAD'] });
196
196
  },
197
197
 
198
- async senderDigest(token: string, query: string, options?: { maxMessages?: number; maxSenders?: number }): Promise<SenderDigestResult> {
199
- const maxMessages = Math.min(options?.maxMessages ?? 500, 2000);
198
+ async senderDigest(token: string, query: string, options?: { maxMessages?: number; maxSenders?: number; pageToken?: string }): Promise<SenderDigestResult> {
199
+ const maxMessages = Math.min(options?.maxMessages ?? 2000, 2000);
200
200
  const maxSenders = options?.maxSenders ?? 30;
201
- const maxIdsPerSender = 1000;
201
+ const maxIdsPerSender = 2000;
202
202
 
203
203
  const allMessageIds: string[] = [];
204
- let pageToken: string | undefined;
204
+ let pageToken: string | undefined = options?.pageToken;
205
+ let truncated = false;
205
206
 
206
207
  while (allMessageIds.length < maxMessages) {
207
208
  const pageSize = Math.min(100, maxMessages - allMessageIds.length);
@@ -213,6 +214,11 @@ export const gmailMessagingProvider: MessagingProvider = {
213
214
  if (!pageToken) break;
214
215
  }
215
216
 
217
+ // If we stopped because we hit the cap but there were still more pages, flag truncation
218
+ if (allMessageIds.length >= maxMessages && pageToken) {
219
+ truncated = true;
220
+ }
221
+
216
222
  if (allMessageIds.length === 0) {
217
223
  return { senders: [], totalScanned: 0, queryUsed: query };
218
224
  }
@@ -283,7 +289,7 @@ export const gmailMessagingProvider: MessagingProvider = {
283
289
  hasMore: s.hasMore,
284
290
  }));
285
291
 
286
- return { senders, totalScanned: allMessageIds.length, queryUsed: query };
292
+ return { senders, totalScanned: allMessageIds.length, queryUsed: query, ...(truncated ? { truncated, nextPageToken: pageToken } : {}) };
287
293
  },
288
294
 
289
295
  async archiveByQuery(token: string, query: string): Promise<ArchiveResult> {
@@ -33,6 +33,7 @@ export class GmailApiError extends Error {
33
33
 
34
34
  const MAX_RETRIES = 3;
35
35
  const INITIAL_BACKOFF_MS = 1000;
36
+ const REQUEST_TIMEOUT_MS = 30_000;
36
37
 
37
38
  function isRetryable(status: number): boolean {
38
39
  return status === 429 || (status >= 500 && status < 600);
@@ -57,6 +58,7 @@ async function request<T>(token: string, path: string, options?: GmailRequestOpt
57
58
  for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
58
59
  const resp = await fetch(url, {
59
60
  ...options,
61
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
60
62
  headers: {
61
63
  Authorization: `Bearer ${token}`,
62
64
  'Content-Type': 'application/json',
@@ -60,6 +60,7 @@ function mapConversation(conv: SlackConversation): Conversation {
60
60
  memberCount: conv.num_members,
61
61
  topic: conv.topic?.value || undefined,
62
62
  isArchived: conv.is_archived,
63
+ isPrivate: conv.is_private ?? conv.is_group ?? false,
63
64
  metadata: conv.is_im ? { dmUserId: conv.user } : undefined,
64
65
  };
65
66
  }
@@ -10,6 +10,7 @@ import type {
10
10
  SlackAuthTestResponse,
11
11
  SlackChatDeleteResponse,
12
12
  SlackConversationHistoryResponse,
13
+ SlackConversationInfoResponse,
13
14
  SlackConversationLeaveResponse,
14
15
  SlackConversationMarkResponse,
15
16
  SlackConversationRepliesResponse,
@@ -95,6 +96,13 @@ export async function listConversations(
95
96
  });
96
97
  }
97
98
 
99
+ export async function conversationInfo(
100
+ token: string,
101
+ channel: string,
102
+ ): Promise<SlackConversationInfoResponse> {
103
+ return request<SlackConversationInfoResponse>(token, 'conversations.info', { channel });
104
+ }
105
+
98
106
  export async function conversationHistory(
99
107
  token: string,
100
108
  channel: string,
@@ -20,6 +20,7 @@ export interface SlackConversation {
20
20
  is_group?: boolean;
21
21
  is_im?: boolean;
22
22
  is_mpim?: boolean;
23
+ is_private?: boolean;
23
24
  is_archived?: boolean;
24
25
  is_member?: boolean;
25
26
  topic?: { value: string };
@@ -108,6 +109,10 @@ export interface SlackSearchMatch {
108
109
  thread_ts?: string;
109
110
  }
110
111
 
112
+ export interface SlackConversationInfoResponse extends SlackApiResponse {
113
+ channel: SlackConversation;
114
+ }
115
+
111
116
  export interface SlackConversationsOpenResponse extends SlackApiResponse {
112
117
  channel: { id: string };
113
118
  }
@@ -16,16 +16,17 @@
16
16
  * internal assistant-layer errors.
17
17
  */
18
18
  export type HttpErrorCode =
19
- | 'BAD_REQUEST'
20
- | 'UNAUTHORIZED'
21
- | 'FORBIDDEN'
22
- | 'NOT_FOUND'
23
- | 'CONFLICT'
24
- | 'GONE'
25
- | 'RATE_LIMITED'
26
- | 'UNPROCESSABLE_ENTITY'
27
- | 'INTERNAL_ERROR'
28
- | 'SERVICE_UNAVAILABLE';
19
+ | "BAD_REQUEST"
20
+ | "UNAUTHORIZED"
21
+ | "FORBIDDEN"
22
+ | "NOT_FOUND"
23
+ | "CONFLICT"
24
+ | "GONE"
25
+ | "RATE_LIMITED"
26
+ | "UNPROCESSABLE_ENTITY"
27
+ | "INTERNAL_ERROR"
28
+ | "NOT_IMPLEMENTED"
29
+ | "SERVICE_UNAVAILABLE";
29
30
 
30
31
  // ── Response type ────────────────────────────────────────────────────────────
31
32
 
@@ -79,15 +80,27 @@ export function httpError(
79
80
  */
80
81
  export function httpErrorCodeFromStatus(status: number): HttpErrorCode {
81
82
  switch (status) {
82
- case 400: return 'BAD_REQUEST';
83
- case 401: return 'UNAUTHORIZED';
84
- case 403: return 'FORBIDDEN';
85
- case 404: return 'NOT_FOUND';
86
- case 409: return 'CONFLICT';
87
- case 410: return 'GONE';
88
- case 422: return 'UNPROCESSABLE_ENTITY';
89
- case 429: return 'RATE_LIMITED';
90
- case 503: return 'SERVICE_UNAVAILABLE';
91
- default: return 'INTERNAL_ERROR';
83
+ case 400:
84
+ return "BAD_REQUEST";
85
+ case 401:
86
+ return "UNAUTHORIZED";
87
+ case 403:
88
+ return "FORBIDDEN";
89
+ case 404:
90
+ return "NOT_FOUND";
91
+ case 409:
92
+ return "CONFLICT";
93
+ case 410:
94
+ return "GONE";
95
+ case 422:
96
+ return "UNPROCESSABLE_ENTITY";
97
+ case 429:
98
+ return "RATE_LIMITED";
99
+ case 501:
100
+ return "NOT_IMPLEMENTED";
101
+ case 503:
102
+ return "SERVICE_UNAVAILABLE";
103
+ default:
104
+ return "INTERNAL_ERROR";
92
105
  }
93
106
  }