vellum 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +28 -0
  3. package/src/__tests__/app-bundler.test.ts +12 -33
  4. package/src/__tests__/browser-skill-endstate.test.ts +1 -5
  5. package/src/__tests__/call-orchestrator.test.ts +328 -0
  6. package/src/__tests__/call-state.test.ts +133 -0
  7. package/src/__tests__/call-store.test.ts +476 -0
  8. package/src/__tests__/commit-message-enrichment-service.test.ts +409 -0
  9. package/src/__tests__/config-schema.test.ts +49 -0
  10. package/src/__tests__/doordash-session.test.ts +9 -0
  11. package/src/__tests__/ipc-snapshot.test.ts +34 -0
  12. package/src/__tests__/registry.test.ts +13 -8
  13. package/src/__tests__/run-orchestrator-assistant-events.test.ts +218 -0
  14. package/src/__tests__/run-orchestrator.test.ts +3 -3
  15. package/src/__tests__/runtime-attachment-metadata.test.ts +17 -19
  16. package/src/__tests__/runtime-runs-http.test.ts +1 -19
  17. package/src/__tests__/runtime-runs.test.ts +7 -7
  18. package/src/__tests__/session-queue.test.ts +50 -0
  19. package/src/__tests__/turn-commit.test.ts +56 -0
  20. package/src/__tests__/workspace-git-service.test.ts +217 -0
  21. package/src/__tests__/workspace-heartbeat-service.test.ts +129 -0
  22. package/src/bundler/app-bundler.ts +29 -12
  23. package/src/calls/call-constants.ts +10 -0
  24. package/src/calls/call-orchestrator.ts +364 -0
  25. package/src/calls/call-state.ts +64 -0
  26. package/src/calls/call-store.ts +229 -0
  27. package/src/calls/relay-server.ts +298 -0
  28. package/src/calls/twilio-config.ts +34 -0
  29. package/src/calls/twilio-provider.ts +169 -0
  30. package/src/calls/twilio-routes.ts +236 -0
  31. package/src/calls/types.ts +37 -0
  32. package/src/calls/voice-provider.ts +14 -0
  33. package/src/cli/doordash.ts +5 -24
  34. package/src/config/bundled-skills/doordash/SKILL.md +104 -0
  35. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -2
  36. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -1
  37. package/src/config/defaults.ts +11 -0
  38. package/src/config/schema.ts +57 -0
  39. package/src/config/system-prompt.ts +50 -1
  40. package/src/config/types.ts +1 -0
  41. package/src/daemon/handlers/config.ts +30 -0
  42. package/src/daemon/handlers/index.ts +6 -0
  43. package/src/daemon/handlers/work-items.ts +142 -2
  44. package/src/daemon/ipc-contract-inventory.json +12 -0
  45. package/src/daemon/ipc-contract.ts +52 -0
  46. package/src/daemon/lifecycle.ts +27 -5
  47. package/src/daemon/server.ts +10 -12
  48. package/src/daemon/session-tool-setup.ts +6 -0
  49. package/src/daemon/session.ts +40 -1
  50. package/src/index.ts +2 -0
  51. package/src/media/gemini-image-service.ts +1 -1
  52. package/src/memory/db.ts +266 -0
  53. package/src/memory/schema.ts +42 -0
  54. package/src/runtime/http-server.ts +189 -25
  55. package/src/runtime/http-types.ts +0 -2
  56. package/src/runtime/routes/attachment-routes.ts +6 -6
  57. package/src/runtime/routes/channel-routes.ts +16 -18
  58. package/src/runtime/routes/conversation-routes.ts +5 -9
  59. package/src/runtime/routes/run-routes.ts +4 -8
  60. package/src/runtime/run-orchestrator.ts +32 -5
  61. package/src/tools/calls/call-end.ts +117 -0
  62. package/src/tools/calls/call-start.ts +134 -0
  63. package/src/tools/calls/call-status.ts +97 -0
  64. package/src/tools/credentials/vault.ts +1 -1
  65. package/src/tools/registry.ts +2 -4
  66. package/src/tools/tasks/index.ts +2 -0
  67. package/src/tools/tasks/task-delete.ts +49 -8
  68. package/src/tools/tasks/task-run.ts +9 -1
  69. package/src/tools/tasks/work-item-enqueue.ts +93 -3
  70. package/src/tools/tasks/work-item-list.ts +10 -25
  71. package/src/tools/tasks/work-item-remove.ts +112 -0
  72. package/src/tools/tasks/work-item-update.ts +186 -0
  73. package/src/tools/tool-manifest.ts +39 -31
  74. package/src/tools/ui-surface/definitions.ts +3 -0
  75. package/src/work-items/work-item-store.ts +209 -0
  76. package/src/workspace/commit-message-enrichment-service.ts +260 -0
  77. package/src/workspace/commit-message-provider.ts +95 -0
  78. package/src/workspace/git-service.ts +187 -32
  79. package/src/workspace/heartbeat-service.ts +70 -13
  80. package/src/workspace/turn-commit.ts +39 -49
@@ -0,0 +1,236 @@
1
+ /**
2
+ * HTTP route handlers for Twilio voice webhooks.
3
+ *
4
+ * - handleVoiceWebhook: initial voice webhook; returns TwiML to connect ConversationRelay
5
+ * - handleStatusCallback: call status updates (ringing, in-progress, completed, etc.)
6
+ * - handleConnectAction: called when the ConversationRelay connection ends
7
+ */
8
+
9
+ import { getLogger } from '../util/logger.js';
10
+ import {
11
+ getCallSession,
12
+ getCallSessionByCallSid,
13
+ updateCallSession,
14
+ recordCallEvent,
15
+ expirePendingQuestions,
16
+ getPendingQuestion,
17
+ answerPendingQuestion,
18
+ } from './call-store.js';
19
+ import type { CallStatus } from './types.js';
20
+ import { getCallOrchestrator } from './call-state.js';
21
+
22
+ const log = getLogger('twilio-routes');
23
+
24
+ // ── Helpers ──────────────────────────────────────────────────────────
25
+
26
+ function escapeXml(str: string): string {
27
+ return str
28
+ .replace(/&/g, '&')
29
+ .replace(/</g, '&lt;')
30
+ .replace(/>/g, '&gt;')
31
+ .replace(/"/g, '&quot;')
32
+ .replace(/'/g, '&apos;');
33
+ }
34
+
35
+ function generateTwiML(callSessionId: string, wssBaseUrl: string, welcomeGreeting: string): string {
36
+ return `<?xml version="1.0" encoding="UTF-8"?>
37
+ <Response>
38
+ <Connect>
39
+ <ConversationRelay
40
+ url="${escapeXml(wssBaseUrl)}/v1/calls/relay?callSessionId=${escapeXml(callSessionId)}"
41
+ welcomeGreeting="${escapeXml(welcomeGreeting)}"
42
+ voice="Google.en-US-Journey-O"
43
+ language="en-US"
44
+ transcriptionProvider="Deepgram"
45
+ ttsProvider="Google"
46
+ interruptible="true"
47
+ dtmfDetection="true"
48
+ />
49
+ </Connect>
50
+ </Response>`;
51
+ }
52
+
53
+ /**
54
+ * Map Twilio call status strings to our internal CallStatus.
55
+ */
56
+ function mapTwilioStatus(twilioStatus: string): CallStatus | null {
57
+ switch (twilioStatus) {
58
+ case 'queued':
59
+ case 'ringing':
60
+ return 'ringing';
61
+ case 'in-progress':
62
+ return 'in_progress';
63
+ case 'completed':
64
+ return 'completed';
65
+ case 'failed':
66
+ case 'busy':
67
+ case 'no-answer':
68
+ case 'canceled':
69
+ return 'failed';
70
+ default:
71
+ return null;
72
+ }
73
+ }
74
+
75
+ // ── Route handlers ───────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Receives the initial voice webhook when Twilio connects the call.
79
+ * Returns TwiML XML that tells Twilio to open a ConversationRelay WebSocket.
80
+ */
81
+ export async function handleVoiceWebhook(req: Request): Promise<Response> {
82
+ const url = new URL(req.url);
83
+ const callSessionId = url.searchParams.get('callSessionId');
84
+
85
+ if (!callSessionId) {
86
+ log.warn('Voice webhook called without callSessionId');
87
+ return new Response('Missing callSessionId', { status: 400 });
88
+ }
89
+
90
+ const session = getCallSession(callSessionId);
91
+ if (!session) {
92
+ log.warn({ callSessionId }, 'Voice webhook: call session not found');
93
+ return new Response('Call session not found', { status: 404 });
94
+ }
95
+
96
+ // Parse the Twilio POST body to capture CallSid immediately, so status
97
+ // callbacks (keyed by CallSid) can locate this session even if the
98
+ // WebSocket relay hasn't been set up yet.
99
+ const formBody = new URLSearchParams(await req.text());
100
+ const callSid = formBody.get('CallSid');
101
+ if (callSid && callSid !== session.providerCallSid) {
102
+ updateCallSession(callSessionId, { providerCallSid: callSid });
103
+ log.info({ callSessionId, callSid }, 'Stored CallSid from voice webhook');
104
+ }
105
+
106
+ const wssBaseUrl = process.env.WSS_BASE_URL ?? process.env.BASE_URL ?? 'wss://localhost:7821';
107
+ const welcomeGreeting = process.env.CALL_WELCOME_GREETING ?? 'Hello, how can I help you today?';
108
+
109
+ const twiml = generateTwiML(callSessionId, wssBaseUrl, welcomeGreeting);
110
+
111
+ log.info({ callSessionId }, 'Returning ConversationRelay TwiML');
112
+
113
+ return new Response(twiml, {
114
+ status: 200,
115
+ headers: { 'Content-Type': 'text/xml' },
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Receives call status updates from Twilio (POST with form-urlencoded body).
121
+ * Updates the call session status and records events.
122
+ */
123
+ export async function handleStatusCallback(req: Request): Promise<Response> {
124
+ const formBody = new URLSearchParams(await req.text());
125
+ const callSid = formBody.get('CallSid');
126
+ const callStatus = formBody.get('CallStatus');
127
+
128
+ if (!callSid || !callStatus) {
129
+ log.warn({ callSid, callStatus }, 'Status callback missing CallSid or CallStatus');
130
+ return new Response(null, { status: 200 });
131
+ }
132
+
133
+ log.info({ callSid, callStatus }, 'Twilio status callback received');
134
+
135
+ const session = getCallSessionByCallSid(callSid);
136
+ if (!session) {
137
+ log.warn({ callSid, callStatus }, 'Status callback: no call session found for CallSid');
138
+ return new Response(null, { status: 200 });
139
+ }
140
+
141
+ const mappedStatus = mapTwilioStatus(callStatus);
142
+ if (!mappedStatus) {
143
+ log.warn({ callSid, callStatus }, 'Status callback: unknown Twilio status');
144
+ return new Response(null, { status: 200 });
145
+ }
146
+
147
+ // Build updates
148
+ const updates: Parameters<typeof updateCallSession>[1] = {
149
+ status: mappedStatus,
150
+ };
151
+
152
+ if (mappedStatus === 'in_progress' && !session.startedAt) {
153
+ updates.startedAt = Date.now();
154
+ }
155
+
156
+ const isTerminal = mappedStatus === 'completed' || mappedStatus === 'failed';
157
+ if (isTerminal) {
158
+ updates.endedAt = Date.now();
159
+ }
160
+
161
+ updateCallSession(session.id, updates);
162
+
163
+ // Record event
164
+ const eventType = isTerminal
165
+ ? (mappedStatus === 'completed' ? 'call_ended' : 'call_failed')
166
+ : (mappedStatus === 'in_progress' ? 'call_connected' : 'call_started');
167
+
168
+ recordCallEvent(session.id, eventType, {
169
+ twilioStatus: callStatus,
170
+ callSid,
171
+ });
172
+
173
+ // Expire pending questions on terminal status
174
+ if (isTerminal) {
175
+ expirePendingQuestions(session.id);
176
+ }
177
+
178
+ return new Response(null, { status: 200 });
179
+ }
180
+
181
+ /**
182
+ * Called when the ConversationRelay connection ends.
183
+ * Returns empty TwiML to acknowledge.
184
+ */
185
+ export async function handleConnectAction(_req: Request): Promise<Response> {
186
+ log.info('ConversationRelay connect-action callback received');
187
+ return new Response(
188
+ '<?xml version="1.0" encoding="UTF-8"?><Response/>',
189
+ {
190
+ status: 200,
191
+ headers: { 'Content-Type': 'text/xml' },
192
+ },
193
+ );
194
+ }
195
+
196
+ /**
197
+ * Answer a pending question for an active call.
198
+ * POST /v1/calls/:callSessionId/answer
199
+ * Body: { answer: string }
200
+ */
201
+ export async function handleCallAnswer(req: Request, callSessionId: string): Promise<Response> {
202
+ const body = await req.json() as { answer?: string };
203
+ if (!body.answer) {
204
+ return Response.json({ error: 'Missing answer' }, { status: 400 });
205
+ }
206
+
207
+ const question = getPendingQuestion(callSessionId);
208
+ if (!question) {
209
+ return Response.json({ error: 'No pending question found' }, { status: 404 });
210
+ }
211
+
212
+ // Verify the orchestrator exists before attempting to route the answer.
213
+ const orchestrator = getCallOrchestrator(callSessionId);
214
+ if (!orchestrator) {
215
+ log.warn({ callSessionId }, 'handleCallAnswer: no active orchestrator for call session');
216
+ return Response.json({ error: 'No active orchestrator for this call' }, { status: 409 });
217
+ }
218
+
219
+ // Route answer to the orchestrator FIRST — it atomically checks whether it is
220
+ // in the `waiting_on_user` state and transitions to `processing`. Only persist
221
+ // the answer to the DB if the orchestrator actually accepted it, preventing a
222
+ // race where the consultation timer expires between our check and the persist.
223
+ const accepted = await orchestrator.handleUserAnswer(body.answer);
224
+ if (!accepted) {
225
+ log.warn(
226
+ { callSessionId },
227
+ 'handleCallAnswer: orchestrator rejected the answer (not in waiting_on_user state)',
228
+ );
229
+ return Response.json({ error: 'Orchestrator is not waiting for an answer' }, { status: 409 });
230
+ }
231
+
232
+ // Mark question as answered — only after the orchestrator has accepted
233
+ answerPendingQuestion(question.id, body.answer);
234
+
235
+ return Response.json({ ok: true, questionId: question.id });
236
+ }
@@ -0,0 +1,37 @@
1
+ export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed';
2
+ export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'call_ended' | 'call_failed';
3
+ export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
4
+
5
+ export interface CallSession {
6
+ id: string;
7
+ conversationId: string;
8
+ provider: string;
9
+ providerCallSid: string | null;
10
+ fromNumber: string;
11
+ toNumber: string;
12
+ task: string | null;
13
+ status: CallStatus;
14
+ startedAt: number | null;
15
+ endedAt: number | null;
16
+ lastError: string | null;
17
+ createdAt: number;
18
+ updatedAt: number;
19
+ }
20
+
21
+ export interface CallEvent {
22
+ id: string;
23
+ callSessionId: string;
24
+ eventType: CallEventType;
25
+ payloadJson: string;
26
+ createdAt: number;
27
+ }
28
+
29
+ export interface CallPendingQuestion {
30
+ id: string;
31
+ callSessionId: string;
32
+ questionText: string;
33
+ status: PendingQuestionStatus;
34
+ askedAt: number;
35
+ answeredAt: number | null;
36
+ answerText: string | null;
37
+ }
@@ -0,0 +1,14 @@
1
+ export interface InitiateCallOptions {
2
+ from: string;
3
+ to: string;
4
+ webhookUrl: string;
5
+ statusCallbackUrl: string;
6
+ customParams?: Record<string, string>;
7
+ }
8
+
9
+ export interface VoiceProvider {
10
+ name: string;
11
+ initiateCall(opts: InitiateCallOptions): Promise<{ callSid: string }>;
12
+ endCall(callSid: string): Promise<void>;
13
+ getCallStatus(callSid: string): Promise<string>;
14
+ }
@@ -139,8 +139,8 @@ export function registerDoordashCommand(program: Command): void {
139
139
  dd.command('refresh')
140
140
  .description(
141
141
  'Start a Ride Shotgun learn session to capture fresh DoorDash cookies. ' +
142
- 'Opens doordash.com in Chrome — sign in when prompted. ' +
143
- 'NOTE: Chrome will restart with debugging enabled; your tabs will be restored.',
142
+ 'Opens doordash.com in a separate Chrome window — sign in when prompted. ' +
143
+ 'Your existing Chrome and tabs are not affected.',
144
144
  )
145
145
  .option('--duration <seconds>', 'Recording duration in seconds', '180')
146
146
  .action(async (opts: { duration: string }, cmd: Command) => {
@@ -664,7 +664,7 @@ export function registerDoordashCommand(program: Command): void {
664
664
  // Chrome CDP restart helper
665
665
  // ---------------------------------------------------------------------------
666
666
 
667
- import { execSync, spawn as spawnChild } from 'node:child_process';
667
+ import { spawn as spawnChild } from 'node:child_process';
668
668
  import { homedir } from 'node:os';
669
669
  import { join as pathJoin } from 'node:path';
670
670
 
@@ -687,27 +687,8 @@ async function ensureChromeWithCDP(): Promise<void> {
687
687
  // Already running with CDP?
688
688
  if (await isCdpReady()) return;
689
689
 
690
- // Kill existing Chrome gracefully
691
- try {
692
- execSync('osascript -e \'tell application "Google Chrome" to quit\'', {
693
- timeout: 5000,
694
- stdio: 'ignore',
695
- });
696
- } catch {
697
- // Chrome might not be running
698
- }
699
-
700
- // Wait for Chrome to quit
701
- for (let i = 0; i < 30; i++) {
702
- try {
703
- execSync('pgrep -x "Google Chrome"', { stdio: 'ignore' });
704
- await new Promise(r => setTimeout(r, 200));
705
- } catch {
706
- break; // Not running
707
- }
708
- }
709
-
710
- // Relaunch Chrome with CDP flags
690
+ // Launch a separate Chrome instance with CDP flags alongside any existing Chrome.
691
+ // Using a dedicated --user-data-dir allows coexistence without killing the user's browser.
711
692
  const chromeApp =
712
693
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
713
694
  spawnChild(chromeApp, [
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: "DoorDash"
3
+ description: "Order food, groceries, and convenience items from DoorDash using the built-in CLI integration"
4
+ user-invocable: true
5
+ metadata: {"vellum": {"emoji": "\uD83C\uDF55"}}
6
+ ---
7
+
8
+ You can order food from DoorDash for the user using the `vellum doordash` CLI.
9
+
10
+ ## Task Progress Widget
11
+
12
+ When executing a food ordering flow, show live progress using the `task_progress` card template. Before starting, call `ui_show` with:
13
+ ```json
14
+ {
15
+ "surface_type": "card",
16
+ "template": "task_progress",
17
+ "templateData": {
18
+ "title": "Ordering from DoorDash",
19
+ "status": "in_progress",
20
+ "steps": [
21
+ { "label": "Check session", "status": "in_progress" },
22
+ { "label": "Search restaurants", "status": "pending" },
23
+ { "label": "Browse menu", "status": "pending" },
24
+ { "label": "Add to cart", "status": "pending" },
25
+ { "label": "Place order", "status": "pending" }
26
+ ]
27
+ }
28
+ }
29
+ ```
30
+ As each step completes, call `ui_update` with the same surface ID to update step statuses. Add `detail` to completed steps (e.g. `"detail": "Found Andiamo's"`). Adapt the steps to the actual flow (e.g. skip "Search restaurants" if the user named a specific store).
31
+
32
+ ## Typical Flow
33
+
34
+ When the user asks you to order food (e.g. "Order pizza from Andiamo's"):
35
+
36
+ 1. **Check session** — run `vellum doordash status --json`. If `loggedIn` is false or the session is expired, tell the user: "I need to capture your DoorDash session. A separate Chrome window will open — your existing Chrome and tabs are not affected. Please sign in to DoorDash when it opens, and I'll take it from there." Then run `vellum doordash refresh --json`. This starts a Ride Shotgun learn session that records your login and auto-stops once it detects you've signed in. The session is imported automatically. **This command blocks until login is complete — just wait for it.**
37
+
38
+ 2. **Search** — run `vellum doordash search "<query>" --json` to find matching restaurants. Present the top results to the user with name, rating, and delivery info. If the user named a specific restaurant, pick the best match. If ambiguous, ask.
39
+
40
+ 3. **Browse menu** — run `vellum doordash menu <storeId> --json` to get the menu. Show the user the categories and items with prices. If the user already said what they want (e.g. "pepperoni pizza"), find the matching item(s). **For convenience/pharmacy stores** (CVS, Duane Reade, Walgreens etc.), the response will have `isRetail: true` and empty items — use `store-search` instead (see step 3b).
41
+
42
+ 3b. **Search within a retail store** — for convenience/pharmacy stores, run `vellum doordash store-search <storeId> "<query>" --json` to find specific products. This returns items with IDs, prices, and menuIds that can be added to cart directly.
43
+
44
+ 4. **Get item details** (if needed) — run `vellum doordash item <storeId> <itemId> --json` to see options/customizations. If the item has required options (like size or toppings), ask the user or pick sensible defaults.
45
+
46
+ 5. **Add to cart** — run `vellum doordash cart add --store-id <id> --menu-id <id> --item-id <id> --item-name "<name>" --unit-price <cents> --json`. For subsequent items at the same store, pass `--cart-id <id>` from the first add response.
47
+
48
+ 6. **Review cart** — run `vellum doordash cart view <cartId> --json` and show the user what's in their cart with prices. Ask if they want to add anything else or proceed.
49
+
50
+ 7. **Checkout** — run `vellum doordash checkout <cartId> --json` to get delivery options. Present them to the user.
51
+
52
+ 8. **Payment methods** — run `vellum doordash payment-methods --json` to see saved cards. Show the user which card will be used (the default one).
53
+
54
+ 9. **Place order** — after the user explicitly confirms, run `vellum doordash order place --cart-id <id> --store-id <id> --total <cents> [--tip <cents>] [--dropoff-option <id>] --json`. The command auto-selects the default payment method if `--payment-uuid` is not provided. The response contains `orderUuid` on success.
55
+
56
+ ## Important Behavior
57
+
58
+ - **Always confirm before checkout.** Never place an order without explicit user approval.
59
+ - **Be proactive.** If the user says "order pizza from Andiamo's", don't ask clarifying questions upfront — search, find the store, show the menu, and suggest items. Only ask when you need a choice the user hasn't specified.
60
+ - **Handle expired sessions gracefully.** If any command returns `"error": "session_expired"`, run `vellum doordash refresh --json` to re-capture the session.
61
+ - **Show prices.** Always show prices when presenting items or the cart summary.
62
+ - **Use `--json` flag** on all commands for reliable parsing.
63
+ - **Do NOT use the browser skill.** All DoorDash interaction goes through the CLI, not browser automation.
64
+
65
+ ## Command Reference
66
+
67
+ ```
68
+ vellum doordash status --json # Check if logged in
69
+ vellum doordash refresh --json # Capture fresh session via Ride Shotgun (auto-stops after login)
70
+ vellum doordash login --recording <path> # Import session from a recording file manually
71
+ vellum doordash logout --json # Clear session
72
+ vellum doordash search "<query>" --json # Search restaurants
73
+ vellum doordash menu <storeId> --json # Get store menu (auto-detects retail stores)
74
+ vellum doordash store-search <storeId> "<query>" --json # Search items within a convenience/pharmacy store
75
+ vellum doordash item <storeId> <itemId> --json # Get item details + options
76
+ vellum doordash cart add --store-id <id> --menu-id <id> --item-id <id> --item-name "<name>" --unit-price <cents> [--quantity <n>] [--cart-id <id>] --json
77
+ vellum doordash cart remove --cart-id <id> --item-id <orderItemId> --json
78
+ vellum doordash cart view <cartId> --json
79
+ vellum doordash cart list [--store-id <id>] --json
80
+ vellum doordash checkout <cartId> [--address-id <id>] --json
81
+ vellum doordash payment-methods --json # List saved payment methods
82
+ vellum doordash order place --cart-id <id> --store-id <id> --total <cents> [--tip <cents>] [--delivery-option <type>] [--dropoff-option <id>] [--payment-uuid <uuid>] --json
83
+ ```
84
+
85
+ ## Example Interaction
86
+
87
+ **User**: "Order a pepperoni pizza from Andiamo's"
88
+
89
+ 1. `vellum doordash status --json` -> logged in
90
+ 2. `vellum doordash search "Andiamo's" --json` -> finds store 22926474
91
+ 3. `vellum doordash menu 22926474 --json` -> finds "Pepperoni Pizza Pie" (item 2956709006, $28.00)
92
+ 4. Tell user: "I found Pepperoni Pizza Pie at Andiamo's for $28.00. Adding it to your cart."
93
+ 5. `vellum doordash cart add --store-id 22926474 --menu-id 12847574 --item-id 2956709006 --item-name "Pepperoni Pizza Pie" --unit-price 2800 --json`
94
+ 6. `vellum doordash cart view <cartId> --json` -> show summary
95
+ 7. "Your cart has 1x Pepperoni Pizza Pie ($28.00), total $28.00. Ready to check out?"
96
+
97
+ **User**: "I need Tylenol from CVS"
98
+
99
+ 1. `vellum doordash status --json` -> logged in
100
+ 2. `vellum doordash search "CVS" --json` -> finds store 1231787
101
+ 3. `vellum doordash menu 1231787 --json` -> isRetail: true, categories but no items
102
+ 4. `vellum doordash store-search 1231787 "tylenol" --json` -> finds results
103
+ 5. Show top results: "Tylenol Extra Strength Gelcaps (24 ct) - $8.79, Tylenol Extra Strength Caplets (100 ct) - $13.49..."
104
+ 6. User picks one -> add to cart with the item's `id`, `menuId`, and `unitAmount`
@@ -25,8 +25,8 @@
25
25
  },
26
26
  "model": {
27
27
  "type": "string",
28
- "enum": ["gemini-2.5-flash-image", "gemini-3-pro-image"],
29
- "description": "Which model to use for generation (default: gemini-2.5-flash-image)"
28
+ "enum": ["gemini-2.5-flash-image", "gemini-3-pro-image", "gemini-3-pro-image-preview"],
29
+ "description": "Which model to use for generation. If omitted, uses the user's configured preference."
30
30
  },
31
31
  "variants": {
32
32
  "type": "number",
@@ -57,7 +57,7 @@ export async function run(
57
57
  const prompt = input.prompt as string;
58
58
  const mode = (input.mode as 'generate' | 'edit') ?? 'generate';
59
59
  const attachmentIds = input.attachment_ids as string[] | undefined;
60
- const model = input.model as string | undefined;
60
+ const model = (input.model as string | undefined) ?? config.imageGenModel;
61
61
  const variants = input.variants as number | undefined;
62
62
 
63
63
  // Resolve source images from attachments for edit mode
@@ -4,6 +4,7 @@ import type { AssistantConfig } from './types.js';
4
4
  export const DEFAULT_CONFIG: AssistantConfig = {
5
5
  provider: 'anthropic',
6
6
  model: 'claude-opus-4-6', // alias: claude-opus-4
7
+ imageGenModel: 'gemini-2.5-flash-image',
7
8
  apiKeys: {},
8
9
  webSearchProvider: 'perplexity',
9
10
  providerOrder: [],
@@ -183,4 +184,14 @@ export const DEFAULT_CONFIG: AssistantConfig = {
183
184
  install: { nodeManager: 'npm' },
184
185
  allowBundled: null,
185
186
  },
187
+ workspaceGit: {
188
+ turnCommitMaxWaitMs: 4000,
189
+ failureBackoffBaseMs: 2000,
190
+ failureBackoffMaxMs: 60000,
191
+ interactiveGitTimeoutMs: 10000,
192
+ enrichmentQueueSize: 50,
193
+ enrichmentConcurrency: 1,
194
+ enrichmentJobTimeoutMs: 30000,
195
+ enrichmentMaxRetries: 2,
196
+ },
186
197
  };
@@ -703,6 +703,49 @@ export const SkillsInstallConfigSchema = z.object({
703
703
  }).default('npm'),
704
704
  });
705
705
 
706
+ export const WorkspaceGitConfigSchema = z.object({
707
+ turnCommitMaxWaitMs: z
708
+ .number({ error: 'workspaceGit.turnCommitMaxWaitMs must be a number' })
709
+ .int('workspaceGit.turnCommitMaxWaitMs must be an integer')
710
+ .positive('workspaceGit.turnCommitMaxWaitMs must be a positive integer')
711
+ .default(4000),
712
+ failureBackoffBaseMs: z
713
+ .number({ error: 'workspaceGit.failureBackoffBaseMs must be a number' })
714
+ .int('workspaceGit.failureBackoffBaseMs must be an integer')
715
+ .positive('workspaceGit.failureBackoffBaseMs must be a positive integer')
716
+ .default(2000),
717
+ failureBackoffMaxMs: z
718
+ .number({ error: 'workspaceGit.failureBackoffMaxMs must be a number' })
719
+ .int('workspaceGit.failureBackoffMaxMs must be an integer')
720
+ .positive('workspaceGit.failureBackoffMaxMs must be a positive integer')
721
+ .default(60000),
722
+ interactiveGitTimeoutMs: z
723
+ .number({ error: 'workspaceGit.interactiveGitTimeoutMs must be a number' })
724
+ .int('workspaceGit.interactiveGitTimeoutMs must be an integer')
725
+ .positive('workspaceGit.interactiveGitTimeoutMs must be a positive integer')
726
+ .default(10000),
727
+ enrichmentQueueSize: z
728
+ .number({ error: 'workspaceGit.enrichmentQueueSize must be a number' })
729
+ .int('workspaceGit.enrichmentQueueSize must be an integer')
730
+ .positive('workspaceGit.enrichmentQueueSize must be a positive integer')
731
+ .default(50),
732
+ enrichmentConcurrency: z
733
+ .number({ error: 'workspaceGit.enrichmentConcurrency must be a number' })
734
+ .int('workspaceGit.enrichmentConcurrency must be an integer')
735
+ .positive('workspaceGit.enrichmentConcurrency must be a positive integer')
736
+ .default(1),
737
+ enrichmentJobTimeoutMs: z
738
+ .number({ error: 'workspaceGit.enrichmentJobTimeoutMs must be a number' })
739
+ .int('workspaceGit.enrichmentJobTimeoutMs must be an integer')
740
+ .positive('workspaceGit.enrichmentJobTimeoutMs must be a positive integer')
741
+ .default(30000),
742
+ enrichmentMaxRetries: z
743
+ .number({ error: 'workspaceGit.enrichmentMaxRetries must be a number' })
744
+ .int('workspaceGit.enrichmentMaxRetries must be an integer')
745
+ .nonnegative('workspaceGit.enrichmentMaxRetries must be non-negative')
746
+ .default(2),
747
+ });
748
+
706
749
  export const SwarmConfigSchema = z.object({
707
750
  enabled: z
708
751
  .boolean({ error: 'swarm.enabled must be a boolean' })
@@ -754,6 +797,9 @@ export const AssistantConfigSchema = z.object({
754
797
  model: z
755
798
  .string({ error: 'model must be a string' })
756
799
  .default('claude-opus-4-6'),
800
+ imageGenModel: z
801
+ .string({ error: 'imageGenModel must be a string' })
802
+ .default('gemini-2.5-flash-image'),
757
803
  apiKeys: z
758
804
  .record(z.string(), z.string({ error: 'Each apiKeys value must be a string' }))
759
805
  .default({}),
@@ -947,6 +993,16 @@ export const AssistantConfigSchema = z.object({
947
993
  install: { nodeManager: 'npm' },
948
994
  allowBundled: null,
949
995
  }),
996
+ workspaceGit: WorkspaceGitConfigSchema.default({
997
+ turnCommitMaxWaitMs: 4000,
998
+ failureBackoffBaseMs: 2000,
999
+ failureBackoffMaxMs: 60000,
1000
+ interactiveGitTimeoutMs: 10000,
1001
+ enrichmentQueueSize: 50,
1002
+ enrichmentConcurrency: 1,
1003
+ enrichmentJobTimeoutMs: 30000,
1004
+ enrichmentMaxRetries: 2,
1005
+ }),
950
1006
  }).superRefine((config, ctx) => {
951
1007
  if (config.contextWindow.targetInputTokens >= config.contextWindow.maxInputTokens) {
952
1008
  ctx.addIssue({
@@ -1002,3 +1058,4 @@ export type SkillsLoadConfig = z.infer<typeof SkillsLoadConfigSchema>;
1002
1058
  export type SkillsInstallConfig = z.infer<typeof SkillsInstallConfigSchema>;
1003
1059
  export type SwarmConfig = z.infer<typeof SwarmConfigSchema>;
1004
1060
  export type SkillsConfig = z.infer<typeof SkillsConfigSchema>;
1061
+ export type WorkspaceGitConfig = z.infer<typeof WorkspaceGitConfigSchema>;
@@ -200,14 +200,26 @@ function buildTaskScheduleReminderRoutingSection(): string {
200
200
  '',
201
201
  'These three systems serve different purposes. Choose the right one based on user intent:',
202
202
  '',
203
- '### Task Queue (task_list_add / task_list_show)',
203
+ '### Task Queue (task_list_add / task_list_show / task_list_update / task_list_remove)',
204
204
  'For tracking things the user wants to do or remember. Use when the user says:',
205
205
  '- "Add to my tasks", "add to my queue", "put this on my task list"',
206
206
  '- "Track this", "I need to do X", "queue this up"',
207
207
  '- Any request to add a one-off item to their personal to-do list',
208
208
  '',
209
+ 'To modify an existing task, use `task_list_update`:',
210
+ '- "Bump the priority on X", "make X high priority", "move this up"',
211
+ '- "Change the status of X", "mark X as done"',
212
+ '- "Update the notes on X"',
213
+ 'Do NOT use `task_list_add` for updates — it will detect duplicates and suggest using `task_list_update` instead.',
214
+ '',
215
+ 'To remove a task from the queue, use `task_list_remove`:',
216
+ '- "Remove X from my tasks", "delete that task", "clean up the duplicate"',
217
+ '- "Take this off the list", "drop this task"',
218
+ '',
209
219
  'You can create ad-hoc work items by providing just a `title` to `task_list_add` — no existing task template is needed. A lightweight template is auto-created behind the scenes. For reusable task definitions with templates and input schemas, use `task_save` first.',
210
220
  '',
221
+ '**IMPORTANT:** When you call `task_list_show`, the Tasks window opens automatically on the client. Do NOT also create a separate surface/UI (via `ui_show` or `app_create`) to display the task queue. Doing so causes duplicate Task Queue windows. Just call `task_list_show` and let the native window handle the presentation.',
222
+ '',
211
223
  '### Schedules (schedule_create / schedule_list / schedule_update / schedule_delete)',
212
224
  'For recurring automated jobs that run on a cron schedule. Use ONLY when the user explicitly wants:',
213
225
  '- Recurring automation: "every day at 9am", "weekly on Mondays", "every hour"',
@@ -224,6 +236,25 @@ function buildTaskScheduleReminderRoutingSection(): string {
224
236
  '- "Remind me to buy groceries" without a time → task_list_add (it\'s a task, not a timed reminder)',
225
237
  '- "Remind me at 5pm to buy groceries" → reminder (explicit time trigger)',
226
238
  '- "Check my inbox every morning at 8am" → schedule_create (recurring automation)',
239
+ '- "Bump priority on X" → task_list_update (NOT task_list_add)',
240
+ '- "Move this up" / "change this task priority" → task_list_update (NOT task_list_add)',
241
+ '- "Mark X as done" → task_list_update (NOT task_list_add)',
242
+ '- "Remove X from my tasks" → task_list_remove (NOT task_list_update)',
243
+ '- "Delete that task" / "clean up the duplicate" → task_list_remove',
244
+ '',
245
+ '### Entity type routing: work items vs task templates',
246
+ '',
247
+ 'There are two entity types with separate ID spaces:',
248
+ '- **Work items** (the user\'s task queue) — managed by task_list_add, task_list_show, task_list_update, task_list_remove',
249
+ '- **Task templates** (reusable definitions) — managed by task_save, task_list, task_run, task_delete',
250
+ '',
251
+ 'Do NOT pass a work item ID to a task template tool or vice versa:',
252
+ '- Deleting a work item from the queue → task_list_remove (NOT task_delete)',
253
+ '- Deleting a task template → task_delete (NOT task_list_remove)',
254
+ '- Running a task template → task_run with task_id (NOT a work item ID)',
255
+ '- Updating a work item → task_list_update with work_item_id (NOT a task template ID)',
256
+ '',
257
+ 'If an error says "entity mismatch", read the corrective action and selector fields it provides to pick the right tool.',
227
258
  ].join('\n');
228
259
  }
229
260
 
@@ -277,6 +308,7 @@ function buildDynamicUiSection(): string {
277
308
  '- **Tool auto-emissions** (e.g. `get_weather`): handled automatically — do nothing extra',
278
309
  '- **Predefined domain data** (flights, stocks): `ui_show` with `surface_type: "dynamic_page"` and domain component classes',
279
310
  '- **Simple structured data** (key-value, table, list): `ui_show` with `card`/`table`/`list`/`form` surface_type',
311
+ '- **Multi-step tasks** (ordering food, booking, purchasing, multi-phase workflows): `ui_show` with `card` surface_type + `template: "task_progress"` (see below)',
280
312
  '- **Interactive apps only**: `app_create` (calculators, dashboards, tools - NOT text content)',
281
313
  '',
282
314
  '### Loading app tools',
@@ -379,6 +411,23 @@ function buildDynamicUiSection(): string {
379
411
  '- **Data review/selection**: Use a `table` surface with selectable rows',
380
412
  '',
381
413
  'Interactive surfaces provide a better user experience than asking your user to type their choice. Only fall back to plain text when the interaction is conversational or doesn\'t fit a structured format.',
414
+ '',
415
+ '### Task progress for multi-step workflows',
416
+ 'When executing a multi-step task (ordering food, booking a flight, purchasing, research with multiple phases), show live progress using the `task_progress` card template:',
417
+ '',
418
+ '1. **Before starting**, call `ui_show` with `surface_type: "card"`, `template: "task_progress"`, and `templateData`:',
419
+ ' ```json',
420
+ ' { "title": "Ordering from DoorDash", "status": "in_progress", "steps": [',
421
+ ' { "label": "Search restaurants", "status": "in_progress" },',
422
+ ' { "label": "Browse menu", "status": "pending" },',
423
+ ' { "label": "Add to cart", "status": "pending" },',
424
+ ' { "label": "Place order", "status": "pending" }',
425
+ ' ] }',
426
+ ' ```',
427
+ '2. **As each step completes**, call `ui_update` with the same surface ID to update step statuses and move to the next step. Add `detail` to completed steps (e.g. `"detail": "Found 3 nearby stores"`).',
428
+ '3. **On completion**, set the top-level `status` to `"completed"`. On failure, set it to `"failed"` and mark the failing step accordingly.',
429
+ '',
430
+ 'Use this for ANY multi-step workflow where the user benefits from seeing structured progress instead of just "Running a command...".',
382
431
  ].join('\n');
383
432
  }
384
433
 
@@ -29,4 +29,5 @@ export type {
29
29
  SkillsInstallConfig,
30
30
  SwarmConfig,
31
31
  SkillsConfig,
32
+ WorkspaceGitConfig,
32
33
  } from './schema.js';