@vellumai/assistant 0.4.13 → 0.4.15

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 (133) hide show
  1. package/ARCHITECTURE.md +77 -38
  2. package/README.md +10 -12
  3. package/package.json +1 -1
  4. package/src/__tests__/actor-token-service.test.ts +108 -522
  5. package/src/__tests__/channel-approval-routes.test.ts +92 -239
  6. package/src/__tests__/channel-approval.test.ts +100 -0
  7. package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
  8. package/src/__tests__/conversation-routes.test.ts +11 -4
  9. package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
  10. package/src/__tests__/mcp-health-check.test.ts +65 -0
  11. package/src/__tests__/permission-types.test.ts +33 -0
  12. package/src/__tests__/scan-result-store.test.ts +121 -0
  13. package/src/__tests__/session-agent-loop.test.ts +120 -0
  14. package/src/__tests__/session-approval-overrides.test.ts +205 -0
  15. package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
  16. package/src/amazon/client.ts +8 -5
  17. package/src/approvals/guardian-decision-primitive.ts +14 -9
  18. package/src/approvals/guardian-request-resolvers.ts +2 -2
  19. package/src/calls/call-controller.ts +2 -2
  20. package/src/calls/twilio-routes.ts +2 -2
  21. package/src/cli/mcp.ts +3 -3
  22. package/src/cli.ts +24 -0
  23. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
  24. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
  25. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
  26. package/src/config/bundled-skills/messaging/SKILL.md +49 -14
  27. package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
  28. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
  29. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
  30. package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
  31. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
  32. package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
  33. package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
  34. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
  35. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
  36. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
  37. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
  38. package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
  39. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  40. package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
  41. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
  42. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  43. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
  44. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
  45. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
  46. package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
  47. package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
  48. package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
  49. package/src/daemon/approval-generators.ts +6 -3
  50. package/src/daemon/handlers/config-ingress.ts +2 -6
  51. package/src/daemon/handlers/guardian-actions.ts +1 -1
  52. package/src/daemon/handlers/sessions.ts +4 -1
  53. package/src/daemon/handlers/shared.ts +3 -0
  54. package/src/daemon/handlers/skills.ts +32 -0
  55. package/src/daemon/ipc-contract/messages.ts +3 -1
  56. package/src/daemon/ipc-handler.ts +24 -0
  57. package/src/daemon/ipc-validate.ts +1 -1
  58. package/src/daemon/lifecycle.ts +6 -8
  59. package/src/daemon/server.ts +8 -3
  60. package/src/daemon/session-agent-loop.ts +19 -1
  61. package/src/daemon/session-attachments.ts +2 -1
  62. package/src/daemon/session-history.ts +2 -2
  63. package/src/daemon/session-process.ts +5 -9
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session-tool-setup.ts +216 -69
  66. package/src/daemon/session.ts +24 -1
  67. package/src/events/domain-events.ts +1 -1
  68. package/src/events/tool-domain-event-publisher.ts +5 -10
  69. package/src/influencer/client.ts +8 -7
  70. package/src/messaging/providers/gmail/client.ts +33 -1
  71. package/src/messaging/providers/gmail/mime-builder.ts +5 -1
  72. package/src/messaging/providers/sms/adapter.ts +3 -7
  73. package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
  74. package/src/messaging/providers/whatsapp/adapter.ts +3 -7
  75. package/src/notifications/adapters/sms.ts +2 -2
  76. package/src/notifications/adapters/telegram.ts +2 -2
  77. package/src/permissions/prompter.ts +2 -0
  78. package/src/permissions/types.ts +11 -1
  79. package/src/runtime/approval-conversation-turn.ts +4 -0
  80. package/src/runtime/auth/__tests__/context.test.ts +130 -0
  81. package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
  82. package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
  83. package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
  84. package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
  85. package/src/runtime/auth/__tests__/policy.test.ts +29 -0
  86. package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
  87. package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
  88. package/src/runtime/auth/__tests__/subject.test.ts +149 -0
  89. package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
  90. package/src/runtime/auth/context.ts +62 -0
  91. package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
  92. package/src/runtime/auth/external-assistant-id.ts +69 -0
  93. package/src/runtime/auth/index.ts +37 -0
  94. package/src/runtime/auth/middleware.ts +127 -0
  95. package/src/runtime/auth/policy.ts +17 -0
  96. package/src/runtime/auth/route-policy.ts +261 -0
  97. package/src/runtime/auth/scopes.ts +64 -0
  98. package/src/runtime/auth/subject.ts +68 -0
  99. package/src/runtime/auth/token-service.ts +275 -0
  100. package/src/runtime/auth/types.ts +79 -0
  101. package/src/runtime/channel-approval-parser.ts +11 -5
  102. package/src/runtime/channel-approval-types.ts +1 -1
  103. package/src/runtime/channel-approvals.ts +22 -1
  104. package/src/runtime/guardian-action-followup-executor.ts +2 -2
  105. package/src/runtime/guardian-context-resolver.ts +15 -0
  106. package/src/runtime/guardian-decision-types.ts +23 -6
  107. package/src/runtime/guardian-outbound-actions.ts +4 -22
  108. package/src/runtime/guardian-reply-router.ts +5 -3
  109. package/src/runtime/http-server.ts +210 -182
  110. package/src/runtime/http-types.ts +11 -1
  111. package/src/runtime/local-actor-identity.ts +25 -0
  112. package/src/runtime/pending-interactions.ts +1 -0
  113. package/src/runtime/routes/approval-routes.ts +42 -59
  114. package/src/runtime/routes/channel-route-shared.ts +9 -41
  115. package/src/runtime/routes/channel-routes.ts +0 -2
  116. package/src/runtime/routes/conversation-routes.ts +39 -49
  117. package/src/runtime/routes/events-routes.ts +15 -22
  118. package/src/runtime/routes/guardian-action-routes.ts +46 -51
  119. package/src/runtime/routes/guardian-approval-interception.ts +6 -5
  120. package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
  121. package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
  122. package/src/runtime/routes/inbound-message-handler.ts +39 -45
  123. package/src/runtime/routes/pairing-routes.ts +9 -9
  124. package/src/runtime/routes/secret-routes.ts +90 -45
  125. package/src/runtime/routes/surface-action-routes.ts +12 -2
  126. package/src/runtime/routes/trust-rules-routes.ts +13 -0
  127. package/src/runtime/routes/twilio-routes.ts +3 -3
  128. package/src/runtime/session-approval-overrides.ts +86 -0
  129. package/src/security/keychain-to-encrypted-migration.ts +8 -1
  130. package/src/skills/frontmatter.ts +44 -1
  131. package/src/tools/permission-checker.ts +226 -74
  132. package/src/runtime/actor-token-service.ts +0 -234
  133. package/src/runtime/middleware/actor-token.ts +0 -265
@@ -1,133 +1,24 @@
1
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
1
+ import { existsSync, readFileSync } from "node:fs";
4
2
  import { inflateRawSync } from "node:zlib";
5
3
 
6
- import { Database } from "bun:sqlite";
7
4
  import { eq } from "drizzle-orm";
8
- import { drizzle } from "drizzle-orm/bun-sqlite";
9
- import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
10
5
  import { v4 as uuid } from "uuid";
11
6
 
7
+ import {
8
+ addMessage,
9
+ createConversation,
10
+ } from "../../../../memory/conversation-store.js";
11
+ import { getDb } from "../../../../memory/db.js";
12
+ import {
13
+ conversationKeys,
14
+ conversations,
15
+ messages as messagesTable,
16
+ } from "../../../../memory/schema.js";
12
17
  import type {
13
18
  ToolContext,
14
19
  ToolExecutionResult,
15
20
  } from "../../../../tools/types.js";
16
21
 
17
- // -- Inline schema (only the tables this tool touches) --
18
-
19
- const conversations = sqliteTable("conversations", {
20
- id: text("id").primaryKey(),
21
- title: text("title"),
22
- createdAt: integer("created_at").notNull(),
23
- updatedAt: integer("updated_at").notNull(),
24
- totalInputTokens: integer("total_input_tokens").notNull().default(0),
25
- totalOutputTokens: integer("total_output_tokens").notNull().default(0),
26
- totalEstimatedCost: real("total_estimated_cost").notNull().default(0),
27
- contextSummary: text("context_summary"),
28
- contextCompactedMessageCount: integer("context_compacted_message_count")
29
- .notNull()
30
- .default(0),
31
- contextCompactedAt: integer("context_compacted_at"),
32
- threadType: text("thread_type").notNull().default("standard"),
33
- memoryScopeId: text("memory_scope_id").notNull().default("default"),
34
- });
35
-
36
- const messagesTable = sqliteTable("messages", {
37
- id: text("id").primaryKey(),
38
- conversationId: text("conversation_id")
39
- .notNull()
40
- .references(() => conversations.id),
41
- role: text("role").notNull(),
42
- content: text("content").notNull(),
43
- createdAt: integer("created_at").notNull(),
44
- metadata: text("metadata"),
45
- });
46
-
47
- const conversationKeys = sqliteTable("conversation_keys", {
48
- id: text("id").primaryKey(),
49
- conversationKey: text("conversation_key").notNull(),
50
- conversationId: text("conversation_id")
51
- .notNull()
52
- .references(() => conversations.id, { onDelete: "cascade" }),
53
- createdAt: integer("created_at").notNull(),
54
- });
55
-
56
- // -- Inline DB access --
57
-
58
- const schema = { conversations, messages: messagesTable, conversationKeys };
59
-
60
- function getDbPath(): string {
61
- const baseDir = process.env.BASE_DATA_DIR?.trim() || homedir();
62
- return join(baseDir, ".vellum", "workspace", "data", "db", "assistant.db");
63
- }
64
-
65
- let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
66
-
67
- function getDb() {
68
- if (!db) {
69
- const dbPath = getDbPath();
70
- const dbDir = join(dbPath, "..");
71
- mkdirSync(dbDir, { recursive: true });
72
- const sqlite = new Database(dbPath);
73
- sqlite.exec("PRAGMA journal_mode=WAL");
74
- sqlite.exec("PRAGMA foreign_keys = ON");
75
- db = drizzle(sqlite, { schema });
76
- }
77
- return db;
78
- }
79
-
80
- // -- Inline conversation helpers --
81
-
82
- let lastTimestamp = 0;
83
- function monotonicNow(): number {
84
- const now = Date.now();
85
- lastTimestamp = Math.max(now, lastTimestamp + 1);
86
- return lastTimestamp;
87
- }
88
-
89
- function createConversation(title: string) {
90
- const database = getDb();
91
- const now = Date.now();
92
- const id = uuid();
93
- const conversation = {
94
- id,
95
- title,
96
- createdAt: now,
97
- updatedAt: now,
98
- totalInputTokens: 0,
99
- totalOutputTokens: 0,
100
- totalEstimatedCost: 0,
101
- contextSummary: null as string | null,
102
- contextCompactedMessageCount: 0,
103
- contextCompactedAt: null as number | null,
104
- threadType: "standard" as const,
105
- memoryScopeId: "default",
106
- };
107
- database.insert(conversations).values(conversation).run();
108
- return conversation;
109
- }
110
-
111
- function addMessage(conversationId: string, role: string, content: string) {
112
- const database = getDb();
113
- const now = monotonicNow();
114
- const message = {
115
- id: uuid(),
116
- conversationId,
117
- role,
118
- content,
119
- createdAt: now,
120
- };
121
- database.transaction((tx) => {
122
- tx.insert(messagesTable).values(message).run();
123
- tx.update(conversations)
124
- .set({ updatedAt: now })
125
- .where(eq(conversations.id, conversationId))
126
- .run();
127
- });
128
- return message;
129
- }
130
-
131
22
  // -- ChatGPT export format types --
132
23
 
133
24
  interface ChatGPTContent {
@@ -209,7 +100,7 @@ export async function run(
209
100
  };
210
101
  }
211
102
 
212
- const database = getDb();
103
+ const db = getDb();
213
104
  let importedCount = 0;
214
105
  let skippedCount = 0;
215
106
  let messageCount = 0;
@@ -217,7 +108,7 @@ export async function run(
217
108
  for (const conv of imported) {
218
109
  const convKey = `chatgpt:${conv.sourceId}`;
219
110
 
220
- const existing = database
111
+ const existing = db
221
112
  .select()
222
113
  .from(conversationKeys)
223
114
  .where(eq(conversationKeys.conversationKey, convKey))
@@ -231,18 +122,18 @@ export async function run(
231
122
  const conversation = createConversation(conv.title);
232
123
 
233
124
  for (const msg of conv.messages) {
234
- addMessage(conversation.id, msg.role, JSON.stringify(msg.content));
125
+ // Uses the daemon's addMessage which triggers memory indexing
126
+ await addMessage(conversation.id, msg.role, JSON.stringify(msg.content));
235
127
  }
236
128
 
237
129
  // Override timestamps to match ChatGPT originals
238
- database
239
- .update(conversations)
130
+ db.update(conversations)
240
131
  .set({ createdAt: conv.createdAt, updatedAt: conv.updatedAt })
241
132
  .where(eq(conversations.id, conversation.id))
242
133
  .run();
243
134
 
244
135
  // Update message timestamps to match ChatGPT originals
245
- const dbMessages = database
136
+ const dbMessages = db
246
137
  .select({ id: messagesTable.id })
247
138
  .from(messagesTable)
248
139
  .where(eq(messagesTable.conversationId, conversation.id))
@@ -250,15 +141,13 @@ export async function run(
250
141
  .all();
251
142
 
252
143
  for (let i = 0; i < dbMessages.length && i < conv.messages.length; i++) {
253
- database
254
- .update(messagesTable)
144
+ db.update(messagesTable)
255
145
  .set({ createdAt: conv.messages[i].createdAt })
256
146
  .where(eq(messagesTable.id, dbMessages[i].id))
257
147
  .run();
258
148
  }
259
149
 
260
- database
261
- .insert(conversationKeys)
150
+ db.insert(conversationKeys)
262
151
  .values({
263
152
  id: uuid(),
264
153
  conversationKey: convKey,
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
 
3
- import { SessionExpiredError } from "../lib/client.js";
3
+ import { RateLimitError, SessionExpiredError } from "../lib/client.js";
4
4
 
5
5
  describe("SessionExpiredError", () => {
6
6
  it("is an instance of Error", () => {
@@ -38,10 +38,12 @@ describe("expired session classification", () => {
38
38
  // the parsed response structure that cdpFetch evaluates.
39
39
 
40
40
  function classifyResponse(parsed: Record<string, unknown>): Error {
41
- // Mirrors the classification logic from cdpFetch (client.ts lines 154-159)
41
+ // Mirrors the classification logic from cdpFetch (client.ts lines 188-200)
42
42
  if (parsed.__error) {
43
- if (parsed.__status === 403 || parsed.__status === 401) {
43
+ if (parsed.__status === 401) {
44
44
  return new SessionExpiredError("DoorDash session has expired.");
45
+ } else if (parsed.__status === 403) {
46
+ return new RateLimitError("DoorDash rate limit hit (HTTP 403).");
45
47
  }
46
48
  return new Error(
47
49
  (parsed.__message as string) ??
@@ -61,14 +63,14 @@ describe("expired session classification", () => {
61
63
  expect(err.message).toBe("DoorDash session has expired.");
62
64
  });
63
65
 
64
- it("classifies HTTP 403 as SessionExpiredError", () => {
66
+ it("classifies HTTP 403 as RateLimitError", () => {
65
67
  const err = classifyResponse({
66
68
  __error: true,
67
69
  __status: 403,
68
70
  __body: "Forbidden",
69
71
  });
70
- expect(err).toBeInstanceOf(SessionExpiredError);
71
- expect(err.message).toBe("DoorDash session has expired.");
72
+ expect(err).toBeInstanceOf(RateLimitError);
73
+ expect(err.message).toBe("DoorDash rate limit hit (HTTP 403).");
72
74
  });
73
75
 
74
76
  it("classifies HTTP 500 as a generic Error, not session expired", () => {
@@ -13,7 +13,7 @@ Before using any Calendar tool, verify that Google Calendar is connected by atte
13
13
 
14
14
  1. **Do NOT call `credential_store oauth2_connect` yourself.** You do not have valid OAuth client credentials, and fabricating a client_id will cause a "401: invalid_client" error from Google.
15
15
  2. Instead, load the **google-oauth-setup** skill, which walks the user through creating real credentials in Google Cloud Console:
16
- - Call `skill_load` with `skill_id: "google-oauth-setup"` to load the dependency skill.
16
+ - Call `skill_load` with `skill: "google-oauth-setup"` to load the dependency skill.
17
17
  3. Tell the user: _"Google Calendar isn't connected yet. I've loaded a setup guide that will walk you through connecting your Google account — it only takes a couple of minutes."_
18
18
 
19
19
  ## Capabilities
@@ -42,7 +42,7 @@ When the user asks to "connect my email", "set up email", "manage my email", or
42
42
 
43
43
  1. **Try connecting directly first.** Call `credential_store` with `action: "oauth2_connect"` and `service: "gmail"`. The tool auto-fills Google's OAuth endpoints and looks up any previously stored client credentials — so this single call may be all that's needed.
44
44
  2. **If it fails because no client_id is found:** The user needs to create Google Cloud OAuth credentials first. Load the **google-oauth-setup** skill (which depends on **public-ingress** for the redirect URI):
45
- - Call `skill_load` with `skill_id: "google-oauth-setup"` to load the dependency skill.
45
+ - Call `skill_load` with `skill: "google-oauth-setup"` to load the dependency skill.
46
46
  - Tell the user Gmail isn't connected yet and briefly explain what the setup involves, then use `ui_show` with `surface_type: "confirmation"` to ask for permission to start:
47
47
  - **message:** "Ready to set up Gmail?"
48
48
  - **detail:** "I'll open a browser where you sign in to Google, then automate everything else — creating a project, enabling APIs, and connecting your account. Takes 2-3 minutes and you can watch in the browser preview panel."
@@ -55,7 +55,7 @@ When the user asks to "connect my email", "set up email", "manage my email", or
55
55
 
56
56
  1. **Try connecting directly first.** Call `credential_store` with `action: "oauth2_connect"` and `service: "slack"`. The tool auto-fills Slack's OAuth endpoints and looks up any previously stored client credentials.
57
57
  2. **If it fails because no client_id is found:** The user needs to create a Slack App first. Load the **slack-oauth-setup** skill:
58
- - Call `skill_load` with `skill_id: "slack-oauth-setup"` to load the dependency skill.
58
+ - Call `skill_load` with `skill: "slack-oauth-setup"` to load the dependency skill.
59
59
  - Tell the user Slack isn't connected yet and briefly explain what the setup involves, then use `ui_show` with `surface_type: "confirmation"` to ask for permission to start:
60
60
  - **message:** "Ready to set up Slack?"
61
61
  - **detail:** "I'll walk you through creating a Slack App and connecting your workspace. The process takes a few minutes, and I'll ask for your approval before each step."
@@ -68,7 +68,7 @@ When the user asks to "connect my email", "set up email", "manage my email", or
68
68
 
69
69
  Telegram uses a bot token (not OAuth). Load the **telegram-setup** skill (which depends on **public-ingress** for the webhook URL) which automates the full setup:
70
70
 
71
- - Call `skill_load` with `skill_id: "telegram-setup"` to load the dependency skill.
71
+ - Call `skill_load` with `skill: "telegram-setup"` to load the dependency skill.
72
72
  - Tell the user: _"I've loaded a setup guide for Telegram. It will walk you through connecting a Telegram bot to your assistant."_
73
73
 
74
74
  The telegram-setup skill handles: verifying the bot token from @BotFather, generating a webhook secret, registering bot commands, and storing credentials securely via the secure credential prompt flow. **Never accept a Telegram bot token pasted in plaintext chat — always use the secure prompt.** Webhook registration with Telegram is handled automatically by the gateway on startup and whenever credentials change.
@@ -79,7 +79,7 @@ The telegram-setup skill also includes **guardian verification**, which links yo
79
79
 
80
80
  SMS messaging uses Twilio as the telephony provider. Twilio credentials and phone number configuration are shared with the **phone-calls** skill. Load the **sms-setup** skill for complete SMS configuration including compliance and testing:
81
81
 
82
- - Call `skill_load` with `skill_id: "sms-setup"` to load the dependency skill.
82
+ - Call `skill_load` with `skill: "sms-setup"` to load the dependency skill.
83
83
  - Tell the user: _"I've loaded the SMS setup guide. It will walk you through configuring Twilio, handling compliance requirements, and testing SMS delivery."_
84
84
 
85
85
  The sms-setup skill handles: Twilio credential storage (Account SID + Auth Token), phone number provisioning or assignment, public ingress setup, SMS compliance verification, and end-to-end test sending. Once SMS is set up, messaging is available automatically — no additional feature flag is needed.
@@ -90,7 +90,7 @@ The sms-setup skill also includes optional **guardian verification** for SMS, wh
90
90
 
91
91
  If the user asks to verify their guardian identity for any channel (SMS, voice, or Telegram), load the **guardian-verify-setup** skill:
92
92
 
93
- - Call `skill_load` with `skill_id: "guardian-verify-setup"` to load the dependency skill.
93
+ - Call `skill_load` with `skill: "guardian-verify-setup"` to load the dependency skill.
94
94
 
95
95
  The guardian-verify-setup skill handles the full outbound verification flow for all supported channels. It collects the user's destination (phone number or Telegram chat ID/handle), initiates an outbound verification session, and guides the user through entering or replying with the verification code. This is the single source of truth for guardian verification setup -- do not duplicate the verification flow inline.
96
96
 
@@ -231,11 +231,36 @@ When searching Gmail, the query uses Gmail's search operators:
231
231
  | `has:attachment` | `has:attachment` | Messages with attachments |
232
232
  | `label:` | `label:work` | Messages with a specific label |
233
233
 
234
- ## Drafting vs Sending
234
+ ## Drafting vs Sending (Gmail)
235
235
 
236
- - Default to drafting (local draft or Gmail native draft) when the user wants to compose.
237
- - Only send when the user explicitly requests it.
238
- - When uncertain, always default to drafting.
236
+ Gmail uses a **draft-first workflow**. All compose and reply tools create Gmail drafts automatically:
237
+
238
+ - `messaging_send` (Gmail) creates a draft in Gmail Drafts
239
+ - `messaging_reply` (Gmail) → creates a threaded draft with reply-all recipients
240
+ - `gmail_draft` → creates a draft
241
+ - `gmail_send_with_attachments` → creates a draft with attachments
242
+ - `gmail_forward` → creates a forward draft
243
+
244
+ **To actually send**: Use `gmail_send_draft` with the draft ID after the user has reviewed it. Only call `gmail_send_draft` when the user explicitly says "send it" or equivalent.
245
+
246
+ **Reply-all**: `messaging_reply` for Gmail automatically builds the reply-all recipient list from the thread. You do not need to manually look up recipients.
247
+
248
+ Non-Gmail platforms (Slack, Telegram, SMS) send directly via `messaging_send` / `messaging_reply`.
249
+
250
+ ## Email Threading (Gmail)
251
+
252
+ When replying to or continuing an email thread:
253
+
254
+ - Use `messaging_reply` with the thread's `thread_id` — it automatically handles threading, reply-all recipients, and subject lines.
255
+ - The `in_reply_to` field on `gmail_draft` requires the **RFC 822 Message-ID header** (looks like `<CABx...@mail.gmail.com>`), NOT the Gmail message ID (which looks like `18e4a5b2c3d4e5f6`). Get it by reading the thread messages and extracting the `Message-ID` header.
256
+
257
+ ## Date Verification
258
+
259
+ Before composing any email that references a date or time:
260
+
261
+ 1. Check the `<temporal_context>` block in the current turn for today's date and upcoming dates
262
+ 2. Verify that "tomorrow" means the day after today's date, "next week" means the upcoming Monday–Friday, etc.
263
+ 3. If the email references a date from another message, cross-check it against the temporal context to ensure it's in the future
239
264
 
240
265
  ## Notifications vs Messages
241
266
 
@@ -291,9 +316,9 @@ When a user asks to declutter, clean up, or organize their email — start scann
291
316
  - **Show a `task_progress` card** with one step per selected sender (e.g., "Archiving TechCrunch (247 emails)"). Update each step from `in_progress` → `completed` as each sender finishes.
292
317
  - When all senders are processed, set the progress card's `status: "completed"`.
293
318
  4. **Act on selection**: For each selected sender:
294
- - Use `gmail_batch_archive` (or `messaging_archive_by_sender` for non-Gmail) with the sender's `message_ids` array — this archives exactly the messages that were scanned and counted
319
+ - Use `gmail_batch_archive` (or `messaging_archive_by_sender` for non-Gmail) with `scan_id` + the selected senders' `id` values as `sender_ids` — this resolves message IDs server-side without putting them in context
295
320
  - If Gmail and the action is "Archive & Unsubscribe" and `has_unsubscribe` is true, call `gmail_unsubscribe` with the sender's `newest_message_id`
296
- 5. **Accurate summary**: The scan counts are exact — the `message_count` shown in the table matches the number of `message_ids` that were archived. Format: "Cleaned up [total_archived] emails from [sender_count] senders." For Gmail, append: "Unsubscribed from [unsub_count]."
321
+ 5. **Accurate summary**: The scan counts are exact — the `message_count` shown in the table matches the number of messages archived. Format: "Cleaned up [total_archived] emails from [sender_count] senders." For Gmail, append: "Unsubscribed from [unsub_count]."
297
322
  6. **Ongoing protection offer (Gmail only)**: After reporting results, offer auto-archive filters:
298
323
  - "Want me to set up auto-archive filters so future emails from these senders skip your inbox?"
299
324
  - If yes, call `gmail_filters` with `action: "create"` for each sender with `from` set to the sender's email and `remove_label_ids: ["INBOX"]`.
@@ -303,10 +328,20 @@ When a user asks to declutter, clean up, or organize their email — start scann
303
328
 
304
329
  - **Zero results**: Tell the user "No newsletter emails found" and suggest broadening the query (e.g. removing the category filter or extending the date range)
305
330
  - **Unsubscribe failures**: Report per-sender success/failure; the existing `gmail_unsubscribe` tool handles edge cases
306
- - **Large sender counts**: The scan covers up to 2000 messages. If `truncated` is true in the top-level response, the scan was capped and there are more matching emails beyond what was scanned — tell the user the cleanup was partial and offer to run another pass. If `has_more` is true for a sender, it means they had more messages than could be tracked — mention this to the user in the summary
331
+ - **Truncation handling**: The scan covers up to 5000 messages (default 2000). If `truncated` is true:
332
+ - **Default**: The top senders are captured — this is usually fine. Mention "partial scan" in the summary.
333
+ - **Comprehensive** (user said "full inbox", "everything", "all of it"): Silently continue scanning with `page_token`, merge results across passes, and present once when complete. Don't ask — just keep going until done.
334
+
335
+ ### Scan ID
336
+
337
+ Scan tools (`gmail_sender_digest`, `gmail_outreach_scan`, `messaging_sender_digest`) return a `scan_id` that references message IDs stored server-side. This keeps thousands of message IDs out of the conversation context.
338
+
339
+ - Pass `scan_id` + `sender_ids` to `gmail_batch_archive` instead of `message_ids`
340
+ - Scan results expire after **30 minutes** — if archiving fails with an expiration error, re-run the scan
341
+ - Raw `message_ids` still work as a fallback for non-scan workflows
307
342
 
308
343
  ## Batch Operations
309
344
 
310
- - Gmail batch tools (`gmail_batch_archive`, `gmail_batch_label`) accept arrays of message IDs.
311
- - First search or list messages to collect IDs, then apply batch actions.
345
+ - Gmail batch tools (`gmail_batch_archive`, `gmail_batch_label`) support `scan_id` + `sender_ids` (preferred) or raw `message_ids`.
346
+ - First scan to get a `scan_id`, then apply batch actions using it.
312
347
  - Always confirm with the user before batch operations on large numbers of messages.
@@ -114,7 +114,7 @@
114
114
  },
115
115
  {
116
116
  "name": "messaging_send",
117
- "description": "Send a message on a platform. This is a high-risk action that always requires user approval. Include a confidence score (0-1).",
117
+ "description": "Compose a message. For Gmail, creates a draft for review; for other platforms, sends directly. High-risk action. Include a confidence score (0-1).",
118
118
  "category": "messaging",
119
119
  "risk": "high",
120
120
  "input_schema": {
@@ -156,7 +156,7 @@
156
156
  },
157
157
  {
158
158
  "name": "messaging_reply",
159
- "description": "Reply in a thread. Medium-risk action. Include a confidence score (0-1).",
159
+ "description": "Reply in a thread. For Gmail, creates a threaded draft with reply-all recipients; for other platforms, sends directly. Include a confidence score (0-1).",
160
160
  "category": "messaging",
161
161
  "risk": "medium",
162
162
  "input_schema": {
@@ -339,18 +339,29 @@
339
339
  },
340
340
  {
341
341
  "name": "gmail_batch_archive",
342
- "description": "Archive multiple Gmail messages at once. Include a confidence score (0-1).",
342
+ "description": "Archive multiple Gmail messages at once. Prefer scan_id + sender_ids (from a prior scan) over raw message_ids. Include a confidence score (0-1).",
343
343
  "category": "messaging",
344
344
  "risk": "medium",
345
345
  "input_schema": {
346
346
  "type": "object",
347
347
  "properties": {
348
+ "scan_id": {
349
+ "type": "string",
350
+ "description": "Scan result ID from a prior gmail_sender_digest or gmail_outreach_scan call"
351
+ },
352
+ "sender_ids": {
353
+ "type": "array",
354
+ "items": {
355
+ "type": "string"
356
+ },
357
+ "description": "Sender IDs to archive (used with scan_id to resolve message IDs server-side)"
358
+ },
348
359
  "message_ids": {
349
360
  "type": "array",
350
361
  "items": {
351
362
  "type": "string"
352
363
  },
353
- "description": "Gmail message IDs to archive"
364
+ "description": "Gmail message IDs to archive (fallback — prefer scan_id + sender_ids)"
354
365
  },
355
366
  "confidence": {
356
367
  "type": "number",
@@ -358,7 +369,6 @@
358
369
  }
359
370
  },
360
371
  "required": [
361
- "message_ids",
362
372
  "confidence"
363
373
  ]
364
374
  },
@@ -543,7 +553,15 @@
543
553
  },
544
554
  "in_reply_to": {
545
555
  "type": "string",
546
- "description": "Message-ID header of the email being replied to"
556
+ "description": "RFC 822 Message-ID header value (e.g. `<CABx...@mail.gmail.com>`), NOT the Gmail message ID. Look up the original message's Message-ID header."
557
+ },
558
+ "cc": {
559
+ "type": "string",
560
+ "description": "CC recipients (comma-separated email addresses)"
561
+ },
562
+ "bcc": {
563
+ "type": "string",
564
+ "description": "BCC recipients (comma-separated email addresses)"
547
565
  }
548
566
  },
549
567
  "required": [
@@ -555,6 +573,31 @@
555
573
  "executor": "tools/gmail-draft.ts",
556
574
  "execution_target": "host"
557
575
  },
576
+ {
577
+ "name": "gmail_send_draft",
578
+ "description": "Send an existing Gmail draft. Only use when the user has reviewed and explicitly approved sending.",
579
+ "category": "messaging",
580
+ "risk": "high",
581
+ "input_schema": {
582
+ "type": "object",
583
+ "properties": {
584
+ "draft_id": {
585
+ "type": "string",
586
+ "description": "Gmail draft ID to send"
587
+ },
588
+ "confidence": {
589
+ "type": "number",
590
+ "description": "Confidence score (0-1) for this action"
591
+ }
592
+ },
593
+ "required": [
594
+ "draft_id",
595
+ "confidence"
596
+ ]
597
+ },
598
+ "executor": "tools/gmail-send-draft.ts",
599
+ "execution_target": "host"
600
+ },
558
601
  {
559
602
  "name": "gmail_list_attachments",
560
603
  "description": "List attachments on a Gmail message with filename, MIME type, size, and attachment ID.",
@@ -607,7 +650,7 @@
607
650
  },
608
651
  {
609
652
  "name": "gmail_send_with_attachments",
610
- "description": "Send an email with file attachments. High-risk action requiring user approval. Include a confidence score (0-1).",
653
+ "description": "Create a Gmail draft with file attachments for review. Include a confidence score (0-1).",
611
654
  "category": "messaging",
612
655
  "risk": "high",
613
656
  "input_schema": {
@@ -658,7 +701,7 @@
658
701
  },
659
702
  {
660
703
  "name": "gmail_forward",
661
- "description": "Forward a Gmail message to another recipient, preserving attachments. Include a confidence score (0-1).",
704
+ "description": "Create a draft forwarding a Gmail message to another recipient, preserving attachments. Include a confidence score (0-1).",
662
705
  "category": "messaging",
663
706
  "risk": "high",
664
707
  "input_schema": {
@@ -916,7 +959,7 @@
916
959
  },
917
960
  "max_messages": {
918
961
  "type": "number",
919
- "description": "Maximum messages to scan (default 2000, cap 2000)"
962
+ "description": "Maximum messages to scan (default 2000, cap 5000)"
920
963
  },
921
964
  "max_senders": {
922
965
  "type": "number",
@@ -1,28 +1,52 @@
1
- import { batchModifyMessages } from '../../../../messaging/providers/gmail/client.js';
2
- import { getMessagingProvider } from '../../../../messaging/registry.js';
3
- import { withValidToken } from '../../../../security/token-manager.js';
4
- import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
5
- import { err,ok } from './shared.js';
1
+ import { batchModifyMessages } from "../../../../messaging/providers/gmail/client.js";
2
+ import { getMessagingProvider } from "../../../../messaging/registry.js";
3
+ import { withValidToken } from "../../../../security/token-manager.js";
4
+ import type {
5
+ ToolContext,
6
+ ToolExecutionResult,
7
+ } from "../../../../tools/types.js";
8
+ import { getSenderMessageIds } from "./scan-result-store.js";
9
+ import { err, ok } from "./shared.js";
6
10
 
7
11
  const BATCH_MODIFY_LIMIT = 1000;
8
12
 
9
- export async function run(input: Record<string, unknown>, context: ToolContext): Promise<ToolExecutionResult> {
13
+ export async function run(
14
+ input: Record<string, unknown>,
15
+ context: ToolContext,
16
+ ): Promise<ToolExecutionResult> {
10
17
  if (!context.triggeredBySurfaceAction) {
11
- return err('This tool requires user confirmation via a surface action. Present results in a selection table with action buttons and wait for the user to click before proceeding.');
18
+ return err(
19
+ "This tool requires user confirmation via a surface action. Present results in a selection table with action buttons and wait for the user to click before proceeding.",
20
+ );
12
21
  }
13
22
 
14
- const messageIds = input.message_ids as string[];
23
+ const scanId = input.scan_id as string | undefined;
24
+ const senderIds = input.sender_ids as string[] | undefined;
25
+ let messageIds = input.message_ids as string[] | undefined;
26
+
27
+ // Resolve message IDs from scan store if scan_id is provided
28
+ if (scanId && senderIds?.length) {
29
+ const resolved = getSenderMessageIds(scanId, senderIds);
30
+ if (!resolved) {
31
+ return err(
32
+ "Scan results have expired (30-minute window). Please re-run the scan to get fresh results.",
33
+ );
34
+ }
35
+ messageIds = resolved;
36
+ }
15
37
 
16
38
  if (!messageIds?.length) {
17
- return err('message_ids is required and must not be empty.');
39
+ return err(
40
+ "Either message_ids or scan_id + sender_ids is required, and must resolve to at least one message.",
41
+ );
18
42
  }
19
43
 
20
44
  try {
21
- const provider = getMessagingProvider('gmail');
45
+ const provider = getMessagingProvider("gmail");
22
46
  return withValidToken(provider.credentialService, async (token) => {
23
47
  for (let i = 0; i < messageIds.length; i += BATCH_MODIFY_LIMIT) {
24
48
  const chunk = messageIds.slice(i, i + BATCH_MODIFY_LIMIT);
25
- await batchModifyMessages(token, chunk, { removeLabelIds: ['INBOX'] });
49
+ await batchModifyMessages(token, chunk, { removeLabelIds: ["INBOX"] });
26
50
  }
27
51
  return ok(`Archived ${messageIds.length} message(s).`);
28
52
  });
@@ -9,6 +9,8 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
9
9
  const subject = input.subject as string;
10
10
  const body = input.body as string;
11
11
  const inReplyTo = input.in_reply_to as string | undefined;
12
+ const cc = input.cc as string | undefined;
13
+ const bcc = input.bcc as string | undefined;
12
14
 
13
15
  if (!to) return err('to is required.');
14
16
  if (!subject) return err('subject is required.');
@@ -17,7 +19,7 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
17
19
  try {
18
20
  const provider = getMessagingProvider('gmail');
19
21
  return withValidToken(provider.credentialService, async (token) => {
20
- const draft = await createDraft(token, to, subject, body, inReplyTo);
22
+ const draft = await createDraft(token, to, subject, body, inReplyTo, cc, bcc);
21
23
  return ok(`Draft created (ID: ${draft.id}). It will appear in your Gmail Drafts.`);
22
24
  });
23
25
  } catch (e) {
@@ -1,4 +1,4 @@
1
- import { getAttachment, getMessage, sendMessageRaw } from '../../../../messaging/providers/gmail/client.js';
1
+ import { createDraftRaw, getAttachment, getMessage } from '../../../../messaging/providers/gmail/client.js';
2
2
  import { buildMultipartMime } from '../../../../messaging/providers/gmail/mime-builder.js';
3
3
  import type { GmailMessagePart } from '../../../../messaging/providers/gmail/types.js';
4
4
  import { getMessagingProvider } from '../../../../messaging/registry.js';
@@ -88,14 +88,13 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
88
88
 
89
89
  if (attachments.length > 0) {
90
90
  const raw = buildMultipartMime({ to: forwardTo, subject, body: forwardHeader, attachments });
91
- const result = await sendMessageRaw(token, raw);
92
- return ok(`Message forwarded to ${forwardTo} with ${attachments.length} attachment(s) (ID: ${result.id}).`);
91
+ const draft = await createDraftRaw(token, raw);
92
+ return ok(`Forward draft created to ${forwardTo} with ${attachments.length} attachment(s) (Draft ID: ${draft.id}). Review in Gmail Drafts, then tell me to send it or send it yourself.`);
93
93
  }
94
94
 
95
- // No attachments — use sendMessageRaw with a simple text MIME
96
95
  const raw = buildMultipartMime({ to: forwardTo, subject, body: forwardHeader, attachments: [] });
97
- const result = await sendMessageRaw(token, raw);
98
- return ok(`Message forwarded to ${forwardTo} (ID: ${result.id}).`);
96
+ const draft = await createDraftRaw(token, raw);
97
+ return ok(`Forward draft created to ${forwardTo} (Draft ID: ${draft.id}). Review in Gmail Drafts, then tell me to send it or send it yourself.`);
99
98
  });
100
99
  } catch (e) {
101
100
  return err(e instanceof Error ? e.message : String(e));
@@ -4,6 +4,7 @@ import { batchGetMessages,listMessages } from '../../../../messaging/providers/g
4
4
  import { getMessagingProvider } from '../../../../messaging/registry.js';
5
5
  import { withValidToken } from '../../../../security/token-manager.js';
6
6
  import type { ToolContext, ToolExecutionResult } from '../../../../tools/types.js';
7
+ import { storeScanResult } from './scan-result-store.js';
7
8
  import { err,ok } from './shared.js';
8
9
 
9
10
  const MAX_MESSAGES_CAP = 2000;
@@ -210,14 +211,21 @@ export async function run(input: Record<string, unknown>, _context: ToolContext)
210
211
  newest_message_id: s.newestMessageId,
211
212
  oldest_date: s.oldestDate,
212
213
  newest_date: s.newestDate,
213
- message_ids: s.messageIds,
214
- has_more: s.hasMore,
215
214
  search_query: `from:${s.email}`,
216
215
  sample_subjects: s.sampleSubjects,
217
216
  suggested_actions: buildSuggestedActions(s.email, s.messageCount),
218
217
  }));
219
218
 
219
+ // Store message IDs server-side to keep them out of LLM context
220
+ const scanId = storeScanResult(sorted.map((s) => ({
221
+ id: Buffer.from(s.email).toString('base64url'),
222
+ messageIds: s.messageIds,
223
+ newestMessageId: s.newestMessageId,
224
+ newestUnsubscribableMessageId: null,
225
+ })));
226
+
220
227
  return ok(JSON.stringify({
228
+ scan_id: scanId,
221
229
  senders,
222
230
  total_scanned: allMessageIds.length,
223
231
  outreach_detected: totalOutreachDetected,