@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.
- package/ARCHITECTURE.md +77 -38
- package/README.md +10 -12
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +108 -522
- package/src/__tests__/channel-approval-routes.test.ts +92 -239
- package/src/__tests__/channel-approval.test.ts +100 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
- package/src/__tests__/conversation-routes.test.ts +11 -4
- package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
- package/src/__tests__/mcp-health-check.test.ts +65 -0
- package/src/__tests__/permission-types.test.ts +33 -0
- package/src/__tests__/scan-result-store.test.ts +121 -0
- package/src/__tests__/session-agent-loop.test.ts +120 -0
- package/src/__tests__/session-approval-overrides.test.ts +205 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
- package/src/amazon/client.ts +8 -5
- package/src/approvals/guardian-decision-primitive.ts +14 -9
- package/src/approvals/guardian-request-resolvers.ts +2 -2
- package/src/calls/call-controller.ts +2 -2
- package/src/calls/twilio-routes.ts +2 -2
- package/src/cli/mcp.ts +3 -3
- package/src/cli.ts +24 -0
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
- package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
- package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
- package/src/config/bundled-skills/messaging/SKILL.md +49 -14
- package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
- package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
- package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
- package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
- package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
- package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
- package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
- package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
- package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
- package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/approval-generators.ts +6 -3
- package/src/daemon/handlers/config-ingress.ts +2 -6
- package/src/daemon/handlers/guardian-actions.ts +1 -1
- package/src/daemon/handlers/sessions.ts +4 -1
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +32 -0
- package/src/daemon/ipc-contract/messages.ts +3 -1
- package/src/daemon/ipc-handler.ts +24 -0
- package/src/daemon/ipc-validate.ts +1 -1
- package/src/daemon/lifecycle.ts +6 -8
- package/src/daemon/server.ts +8 -3
- package/src/daemon/session-agent-loop.ts +19 -1
- package/src/daemon/session-attachments.ts +2 -1
- package/src/daemon/session-history.ts +2 -2
- package/src/daemon/session-process.ts +5 -9
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/daemon/session-tool-setup.ts +216 -69
- package/src/daemon/session.ts +24 -1
- package/src/events/domain-events.ts +1 -1
- package/src/events/tool-domain-event-publisher.ts +5 -10
- package/src/influencer/client.ts +8 -7
- package/src/messaging/providers/gmail/client.ts +33 -1
- package/src/messaging/providers/gmail/mime-builder.ts +5 -1
- package/src/messaging/providers/sms/adapter.ts +3 -7
- package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
- package/src/messaging/providers/whatsapp/adapter.ts +3 -7
- package/src/notifications/adapters/sms.ts +2 -2
- package/src/notifications/adapters/telegram.ts +2 -2
- package/src/permissions/prompter.ts +2 -0
- package/src/permissions/types.ts +11 -1
- package/src/runtime/approval-conversation-turn.ts +4 -0
- package/src/runtime/auth/__tests__/context.test.ts +130 -0
- package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
- package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
- package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
- package/src/runtime/auth/__tests__/policy.test.ts +29 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
- package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
- package/src/runtime/auth/__tests__/subject.test.ts +149 -0
- package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
- package/src/runtime/auth/context.ts +62 -0
- package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
- package/src/runtime/auth/external-assistant-id.ts +69 -0
- package/src/runtime/auth/index.ts +37 -0
- package/src/runtime/auth/middleware.ts +127 -0
- package/src/runtime/auth/policy.ts +17 -0
- package/src/runtime/auth/route-policy.ts +261 -0
- package/src/runtime/auth/scopes.ts +64 -0
- package/src/runtime/auth/subject.ts +68 -0
- package/src/runtime/auth/token-service.ts +275 -0
- package/src/runtime/auth/types.ts +79 -0
- package/src/runtime/channel-approval-parser.ts +11 -5
- package/src/runtime/channel-approval-types.ts +1 -1
- package/src/runtime/channel-approvals.ts +22 -1
- package/src/runtime/guardian-action-followup-executor.ts +2 -2
- package/src/runtime/guardian-context-resolver.ts +15 -0
- package/src/runtime/guardian-decision-types.ts +23 -6
- package/src/runtime/guardian-outbound-actions.ts +4 -22
- package/src/runtime/guardian-reply-router.ts +5 -3
- package/src/runtime/http-server.ts +210 -182
- package/src/runtime/http-types.ts +11 -1
- package/src/runtime/local-actor-identity.ts +25 -0
- package/src/runtime/pending-interactions.ts +1 -0
- package/src/runtime/routes/approval-routes.ts +42 -59
- package/src/runtime/routes/channel-route-shared.ts +9 -41
- package/src/runtime/routes/channel-routes.ts +0 -2
- package/src/runtime/routes/conversation-routes.ts +39 -49
- package/src/runtime/routes/events-routes.ts +15 -22
- package/src/runtime/routes/guardian-action-routes.ts +46 -51
- package/src/runtime/routes/guardian-approval-interception.ts +6 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
- package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +39 -45
- package/src/runtime/routes/pairing-routes.ts +9 -9
- package/src/runtime/routes/secret-routes.ts +90 -45
- package/src/runtime/routes/surface-action-routes.ts +12 -2
- package/src/runtime/routes/trust-rules-routes.ts +13 -0
- package/src/runtime/routes/twilio-routes.ts +3 -3
- package/src/runtime/session-approval-overrides.ts +86 -0
- package/src/security/keychain-to-encrypted-migration.ts +8 -1
- package/src/skills/frontmatter.ts +44 -1
- package/src/tools/permission-checker.ts +226 -74
- package/src/runtime/actor-token-service.ts +0 -234
- package/src/runtime/middleware/actor-token.ts +0 -265
|
@@ -1,133 +1,24 @@
|
|
|
1
|
-
import { existsSync,
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
41
|
+
// Mirrors the classification logic from cdpFetch (client.ts lines 188-200)
|
|
42
42
|
if (parsed.__error) {
|
|
43
|
-
if (parsed.__status ===
|
|
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
|
|
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(
|
|
71
|
-
expect(err.message).toBe("DoorDash
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
-
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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`)
|
|
311
|
-
- First
|
|
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": "
|
|
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.
|
|
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
|
|
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": "
|
|
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": "
|
|
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
|
|
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
|
|
2
|
-
import { getMessagingProvider } from
|
|
3
|
-
import { withValidToken } from
|
|
4
|
-
import type {
|
|
5
|
-
|
|
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(
|
|
13
|
+
export async function run(
|
|
14
|
+
input: Record<string, unknown>,
|
|
15
|
+
context: ToolContext,
|
|
16
|
+
): Promise<ToolExecutionResult> {
|
|
10
17
|
if (!context.triggeredBySurfaceAction) {
|
|
11
|
-
return err(
|
|
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
|
|
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(
|
|
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(
|
|
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: [
|
|
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
|
|
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
|
|
92
|
-
return ok(`
|
|
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
|
|
98
|
-
return ok(`
|
|
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,
|