@vellumai/assistant 0.10.9-staging.1 → 0.10.9-staging.2

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 (100) hide show
  1. package/AGENTS.md +4 -0
  2. package/knip.json +1 -4
  3. package/openapi.yaml +28 -18
  4. package/package.json +1 -1
  5. package/src/__tests__/always-loaded-tools-guard.test.ts +4 -6
  6. package/src/__tests__/app-routes-csp.test.ts +13 -4
  7. package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +4 -8
  8. package/src/__tests__/disk-pressure-tools.test.ts +4 -4
  9. package/src/__tests__/file-list-tool.test.ts +22 -12
  10. package/src/__tests__/list-all-apps.test.ts +204 -0
  11. package/src/__tests__/messaging-send-tool.test.ts +67 -0
  12. package/src/__tests__/mtime-cache.test.ts +42 -0
  13. package/src/__tests__/oauth-provider-profiles.test.ts +3 -2
  14. package/src/__tests__/outlook-messaging-provider.test.ts +71 -0
  15. package/src/__tests__/plugin-app-serve-routes.test.ts +163 -0
  16. package/src/__tests__/plugin-import-boundary-guard.test.ts +1 -0
  17. package/src/__tests__/scaffold-managed-skill-tool.test.ts +47 -0
  18. package/src/__tests__/subagent-notify-parent.test.ts +4 -3
  19. package/src/__tests__/subagent-tool-filtering.test.ts +21 -21
  20. package/src/__tests__/subagent-tool-gate-mode.test.ts +6 -6
  21. package/src/apps/app-store.ts +302 -2
  22. package/src/cli/AGENTS.md +17 -0
  23. package/src/cli/commands/__tests__/gateway.test.ts +55 -0
  24. package/src/cli/commands/apps.help.ts +17 -11
  25. package/src/cli/commands/apps.ts +5 -15
  26. package/src/cli/commands/gateway.help.ts +30 -0
  27. package/src/cli/commands/gateway.ts +40 -1
  28. package/src/cli/commands/platform/__tests__/status.test.ts +0 -90
  29. package/src/cli/commands/platform/index.help.ts +3 -2
  30. package/src/cli/commands/platform/index.ts +0 -9
  31. package/src/cli/commands/routes.help.ts +13 -7
  32. package/src/cli/commands/routes.ts +5 -3
  33. package/src/cli/commands/status.help.ts +24 -0
  34. package/src/cli/commands/status.ts +29 -0
  35. package/src/cli/lib/__tests__/global-json-option.test.ts +97 -0
  36. package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +1 -0
  37. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +31 -0
  38. package/src/cli/lib/bundled-marketplace.json +187 -173
  39. package/src/cli/lib/global-json-option.ts +41 -0
  40. package/src/cli/lib/plugin-fingerprint.ts +18 -7
  41. package/src/cli/program.ts +5 -0
  42. package/src/config/bundled-skills/messaging/TOOLS.json +2 -2
  43. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +27 -19
  44. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
  45. package/src/config/feature-flag-registry.json +1 -9
  46. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +3 -6
  47. package/src/daemon/__tests__/conversation-tool-setup-plugin-scope.test.ts +3 -6
  48. package/src/daemon/__tests__/conversation-tool-setup.test.ts +8 -5
  49. package/src/daemon/conversation-tool-setup.ts +5 -90
  50. package/src/daemon/conversation.ts +2 -2
  51. package/src/messaging/provider-types.ts +9 -0
  52. package/src/messaging/providers/outlook/adapter.ts +17 -2
  53. package/src/messaging/providers/outlook/client.ts +11 -2
  54. package/src/messaging/providers/outlook/types.ts +9 -0
  55. package/src/monitoring/__tests__/plugin-source-watch.test.ts +37 -0
  56. package/src/monitoring/plugin-source-watch.ts +53 -0
  57. package/src/oauth/seed-providers.ts +6 -0
  58. package/src/persistence/embeddings/__tests__/messages-lexical-index.test.ts +3 -0
  59. package/src/persistence/embeddings/messages-lexical-index.ts +11 -0
  60. package/src/plugin-api/conversation-turn.ts +234 -0
  61. package/src/plugin-api/index.ts +12 -0
  62. package/src/plugins/__tests__/source-fingerprint.test.ts +41 -0
  63. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +48 -0
  64. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +46 -5
  65. package/src/plugins/defaults/memory/graph/tool-handlers.ts +2 -2
  66. package/src/plugins/defaults/memory/graph-topology/__tests__/build-memory-graph.test.ts +81 -2
  67. package/src/plugins/defaults/memory/graph-topology/build-memory-graph.ts +80 -33
  68. package/src/plugins/defaults/memory/memory-retrospective-job.ts +40 -1
  69. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +28 -2
  70. package/src/plugins/mtime-cache.ts +26 -13
  71. package/src/plugins/plugin-tree-walk.ts +30 -0
  72. package/src/plugins/source-fingerprint.ts +9 -2
  73. package/src/plugins/surface-import.ts +28 -0
  74. package/src/providers/speech-to-text/__tests__/vellum-managed-realtime.test.ts +1 -1
  75. package/src/providers/speech-to-text/__tests__/vellum-speech-relay-connection.test.ts +4 -1
  76. package/src/providers/speech-to-text/vellum-speech-relay-connection.ts +5 -0
  77. package/src/runtime/routes/__tests__/gateway-status-routes.test.ts +84 -0
  78. package/src/runtime/routes/__tests__/plugins-routes.test.ts +47 -3
  79. package/src/runtime/routes/__tests__/user-routes-cli.test.ts +154 -0
  80. package/src/runtime/routes/app-management-routes.ts +96 -32
  81. package/src/runtime/routes/app-routes.ts +33 -17
  82. package/src/runtime/routes/gateway-status-routes.ts +69 -0
  83. package/src/runtime/routes/index.ts +2 -0
  84. package/src/runtime/routes/platform-routes.ts +4 -14
  85. package/src/runtime/routes/plugins-routes.ts +58 -1
  86. package/src/runtime/routes/settings-routes.ts +8 -6
  87. package/src/runtime/routes/user-route-dispatcher.ts +6 -86
  88. package/src/runtime/routes/user-route-resolution.ts +141 -0
  89. package/src/runtime/routes/user-routes-cli.ts +56 -35
  90. package/src/telemetry/AGENTS.md +15 -0
  91. package/src/telemetry/__tests__/telemetry-event-fixtures.ts +258 -0
  92. package/src/telemetry/telemetry-wire-source.json +1 -1
  93. package/src/telemetry/telemetry-wire-validation.test.ts +157 -0
  94. package/src/telemetry/telemetry-wire-validation.ts +125 -0
  95. package/src/telemetry/telemetry-wire.generated.ts +60 -9
  96. package/src/telemetry/types.test.ts +38 -0
  97. package/src/telemetry/types.ts +183 -91
  98. package/src/telemetry/usage-telemetry-reporter.ts +5 -0
  99. package/src/telemetry/watchdog-direct-emit.ts +6 -0
  100. package/src/tools/skills/scaffold-managed.ts +43 -11
@@ -18,7 +18,10 @@
18
18
  import { createHash } from "node:crypto";
19
19
  import { readFileSync } from "node:fs";
20
20
 
21
- import { walkPluginTree } from "../../plugins/plugin-tree-walk.js";
21
+ import {
22
+ isGeneratedAppBuildDir,
23
+ walkPluginTree,
24
+ } from "../../plugins/plugin-tree-walk.js";
22
25
 
23
26
  /** Digest algorithm recorded alongside the file map, for forward compatibility. */
24
27
  export type FingerprintAlgorithm = "sha256";
@@ -64,9 +67,13 @@ export function computeFingerprint(
64
67
  exclude: readonly string[] = [],
65
68
  ): Fingerprint {
66
69
  const files: Record<string, string> = {};
67
- walkPluginTree(root, { excludeRootEntries: exclude }, (rel, abs) => {
68
- files[rel] = hashFile(abs);
69
- });
70
+ walkPluginTree(
71
+ root,
72
+ { excludeRootEntries: exclude, excludeDir: isGeneratedAppBuildDir },
73
+ (rel, abs) => {
74
+ files[rel] = hashFile(abs);
75
+ },
76
+ );
70
77
  return { algorithm: "sha256", files };
71
78
  }
72
79
 
@@ -178,9 +185,13 @@ export function computeContentHash(
178
185
  exclude: readonly string[] = [],
179
186
  ): string {
180
187
  const entries: Array<{ rel: string; abs: string }> = [];
181
- walkPluginTree(root, { excludeRootEntries: exclude }, (rel, abs) => {
182
- entries.push({ rel, abs });
183
- });
188
+ walkPluginTree(
189
+ root,
190
+ { excludeRootEntries: exclude, excludeDir: isGeneratedAppBuildDir },
191
+ (rel, abs) => {
192
+ entries.push({ rel, abs });
193
+ },
194
+ );
184
195
  entries.sort((a, b) => a.rel.localeCompare(b.rel));
185
196
 
186
197
  const hash = createHash("sha256");
@@ -55,6 +55,7 @@ import { registerUsageCommand } from "./commands/usage.js";
55
55
  import { registerWatchersCommand } from "./commands/watchers.js";
56
56
  import { registerWebhooksCommand } from "./commands/webhooks.js";
57
57
  import { red } from "./lib/cli-colors.js";
58
+ import { registerGlobalJsonOption } from "./lib/global-json-option.js";
58
59
  import { log } from "./logger.js";
59
60
 
60
61
  /**
@@ -151,6 +152,10 @@ Examples:
151
152
  registerWatchersCommand(program);
152
153
  registerWebhooksCommand(program);
153
154
 
155
+ // Every command accepts `--json` — see registerGlobalJsonOption. Must run
156
+ // after all commands are registered so the whole tree is covered.
157
+ registerGlobalJsonOption(program);
158
+
154
159
  // Fail fast when no assistant workspace exists on disk. The workspace is
155
160
  // created by `vellum hatch` and must be present for any command to work.
156
161
  // Commander handles --help and --version before preAction fires, so those
@@ -125,7 +125,7 @@
125
125
  },
126
126
  {
127
127
  "name": "messaging_send",
128
- "description": "Send a message, reply to a thread, or create a draft. On Gmail, always creates a draft for review. Supports replies (via thread_id), attachments (via attachment_paths, Gmail only), and all messaging platforms.",
128
+ "description": "Send a message, reply to a thread, or create a draft. On Gmail, always creates a draft for review. Supports replies (via thread_id), attachments (via attachment_paths, Gmail and Outlook), and all messaging platforms.",
129
129
  "category": "messaging",
130
130
  "risk": "high",
131
131
  "input_schema": {
@@ -160,7 +160,7 @@
160
160
  "items": {
161
161
  "type": "string"
162
162
  },
163
- "description": "Absolute file paths of attachments to include (Gmail only)"
163
+ "description": "Absolute file paths of attachments to include (Gmail and Outlook)"
164
164
  },
165
165
  "thread_id": {
166
166
  "type": "string",
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { basename } from "node:path";
3
3
 
4
+ import type { OutboundAttachment } from "../../../../messaging/provider-types.js";
4
5
  import {
5
6
  createDraft,
6
7
  createDraftRaw,
@@ -32,6 +33,20 @@ import {
32
33
 
33
34
  const log = getLogger("messaging-send");
34
35
 
36
+ /** Read attachment files from disk into in-memory parts for outbound sending. */
37
+ async function readAttachments(paths: string[]): Promise<OutboundAttachment[]> {
38
+ return Promise.all(
39
+ paths.map(async (filePath) => ({
40
+ filename: basename(filePath),
41
+ mimeType: guessMimeType(filePath),
42
+ data: await readFile(filePath),
43
+ })),
44
+ );
45
+ }
46
+
47
+ /** Email providers that accept file attachments on outbound sends. */
48
+ const ATTACHMENT_CAPABLE_PLATFORMS = new Set(["gmail", "outlook"]);
49
+
35
50
  export async function run(
36
51
  input: Record<string, unknown>,
37
52
  context: ToolContext,
@@ -54,9 +69,12 @@ export async function run(
54
69
  try {
55
70
  const provider = await resolveProvider(platform);
56
71
 
57
- // Non-Gmail platforms: reject attachment_paths
58
- if (provider.id !== "gmail" && attachmentPaths?.length) {
59
- return err("Attachments are only supported on Gmail.");
72
+ // Reject attachments on platforms that can't carry them (e.g. Telegram, WhatsApp).
73
+ if (
74
+ attachmentPaths?.length &&
75
+ !ATTACHMENT_CAPABLE_PLATFORMS.has(provider.id)
76
+ ) {
77
+ return err("Attachments are only supported on Gmail and Outlook.");
60
78
  }
61
79
 
62
80
  const account = input.account as string | undefined;
@@ -125,14 +143,7 @@ export async function run(
125
143
 
126
144
  // With attachments: build multipart MIME for threaded reply
127
145
  if (attachmentPaths?.length) {
128
- const attachments = await Promise.all(
129
- attachmentPaths.map(async (filePath) => {
130
- const data = await readFile(filePath);
131
- const filename = basename(filePath);
132
- const mimeType = guessMimeType(filePath);
133
- return { filename, mimeType, data };
134
- }),
135
- );
146
+ const attachments = await readAttachments(attachmentPaths);
136
147
 
137
148
  const raw = buildMultipartMime({
138
149
  to: toList.join(", "),
@@ -176,14 +187,7 @@ export async function run(
176
187
 
177
188
  // With attachments: build multipart MIME and use createDraftRaw
178
189
  if (attachmentPaths?.length) {
179
- const attachments = await Promise.all(
180
- attachmentPaths.map(async (filePath) => {
181
- const data = await readFile(filePath);
182
- const filename = basename(filePath);
183
- const mimeType = guessMimeType(filePath);
184
- return { filename, mimeType, data };
185
- }),
186
- );
190
+ const attachments = await readAttachments(attachmentPaths);
187
191
 
188
192
  const raw = buildMultipartMime({
189
193
  to: conversationId,
@@ -217,10 +221,14 @@ export async function run(
217
221
  }
218
222
 
219
223
  // Non-Gmail platforms
224
+ const attachments = attachmentPaths?.length
225
+ ? await readAttachments(attachmentPaths)
226
+ : undefined;
220
227
  const result = await provider.sendMessage(conn, conversationId, text, {
221
228
  subject,
222
229
  inReplyTo,
223
230
  threadId,
231
+ attachments,
224
232
  assistantId: context.assistantId,
225
233
  });
226
234
 
@@ -16,18 +16,18 @@ First check whether this is a managed/platform assistant:
16
16
  assistant platform status --json
17
17
  ```
18
18
 
19
- If it reports an available platform assistant, do not install or start ngrok for Twilio. The gateway should use Velay, and `velayTunnel.connected` should become `true` after registration. If this is a local/self-hosted assistant without Velay, run the **public-ingress** skill to set up ngrok or another custom tunnel and configure `ingress.publicBaseUrl`.
19
+ If it reports an available platform assistant, do not install or start ngrok for Twilio. The gateway should use Velay; run `assistant gateway status --json` and confirm it reports a `tunnel` URL (a `{ "tunnel": "..." }` object, not `{}`) after registration. If this is a local/self-hosted assistant without Velay, run the **public-ingress** skill to set up ngrok or another custom tunnel and configure `ingress.publicBaseUrl`.
20
20
 
21
21
  ## Call fails immediately after initiating
22
22
 
23
23
  - Check that the phone number is in E.164 format
24
24
  - Verify Twilio credentials are correct (wrong auth token causes API errors)
25
25
  - On trial accounts, ensure the destination number is verified
26
- - Check that the configured tunnel is still running. For ngrok, use `curl -s http://127.0.0.1:4040/api/tunnels`. For Velay, run `assistant platform status --json` and confirm `velayTunnel.connected` is `true`, or check gateway logs for `Velay tunnel registered`.
26
+ - Check that the configured tunnel is still running. For ngrok, use `curl -s http://127.0.0.1:4040/api/tunnels`. For Velay, run `assistant gateway status --json` and confirm it reports a `tunnel` URL (not `{}`), or check gateway logs for `Velay tunnel registered`.
27
27
 
28
28
  ## Call connects but no audio / one-way audio
29
29
 
30
- - The media-stream WebSocket may not be connecting. If you are using ngrok or a custom tunnel, check that `ingress.publicBaseUrl` is correct and the tunnel is active. If you are using Velay, check `assistant platform status --json`; a 503 from `https://velay.../<assistant-id>/...` usually means the assistant tunnel is not connected or the gateway did not complete the WebSocket open, not that ngrok is required.
30
+ - The media-stream WebSocket may not be connecting. If you are using ngrok or a custom tunnel, check that `ingress.publicBaseUrl` is correct and the tunnel is active. If you are using Velay, check `assistant gateway status --json`; a 503 from `https://velay.../<assistant-id>/...` usually means the assistant tunnel is not connected or the gateway did not complete the WebSocket open, not that ngrok is required.
31
31
  - Verify the assistant is running
32
32
 
33
33
  ## "Number not eligible for caller identity"
@@ -178,14 +178,6 @@
178
178
  "description": "Show a live HUD in the top-right of the conversation with scroll geometry, velocity, update rate, and anchor-preserver activity. Developer diagnostic for investigating scroll jank.",
179
179
  "defaultEnabled": false
180
180
  },
181
- {
182
- "id": "coding-agents-panel",
183
- "scope": "client",
184
- "key": "coding-agents-panel",
185
- "label": "Coding Agents Panel",
186
- "description": "Gate native macOS Coding Agents panel entry points and inline ACP session deep links.",
187
- "defaultEnabled": false
188
- },
189
181
  {
190
182
  "id": "compaction-playground",
191
183
  "scope": "assistant",
@@ -311,7 +303,7 @@
311
303
  "scope": "assistant",
312
304
  "key": "memory-concept-graph",
313
305
  "label": "Memory concept graph",
314
- "description": "Gates the Obsidian-style memory concept graph on the assistant identity page and the backend-agnostic /memory-graph + /memory-graph-node routes that feed it. When off, the identity page shows the skills constellation instead. Only produces a graph on memory-v3 backends. Default off.",
306
+ "description": "Gates the Obsidian-style memory concept graph on its own Memory tab (/assistant/memory) and the backend-agnostic /memory-graph + /memory-graph-node routes that feed it. When off, the Memory tab is hidden; the identity page keeps the skills constellation. Only produces a graph on memory-v3 backends. Default off.",
315
307
  "defaultEnabled": false
316
308
  },
317
309
  {
@@ -16,10 +16,9 @@ import {
16
16
  registerPluginTools,
17
17
  } from "../../tools/registry.js";
18
18
  import type { Tool } from "../../tools/types.js";
19
+ import type { Conversation } from "../conversation.js";
19
20
  import { createResolveToolsCallback } from "../conversation-tool-setup.js";
20
21
 
21
- type SkillProjectionContext =
22
- import("../conversation-tool-setup.js").SkillProjectionContext;
23
22
  type SkillProjectionCache =
24
23
  import("../conversation-skill-tools.js").SkillProjectionCache;
25
24
 
@@ -43,15 +42,13 @@ function pluginTool(name: string): Tool {
43
42
  } as unknown as Tool;
44
43
  }
45
44
 
46
- function makeCtx(
47
- overrides: Partial<SkillProjectionContext> = {},
48
- ): SkillProjectionContext {
45
+ function makeCtx(overrides: Partial<Conversation> = {}): Conversation {
49
46
  return {
50
47
  skillProjectionState: new Map(),
51
48
  skillProjectionCache: { fingerprints: new Map() } as SkillProjectionCache,
52
49
  toolsDisabledDepth: 0,
53
50
  ...overrides,
54
- };
51
+ } as unknown as Conversation;
55
52
  }
56
53
 
57
54
  function withExclude(exclude: string[]) {
@@ -16,10 +16,9 @@ import {
16
16
  registerPluginTools,
17
17
  } from "../../tools/registry.js";
18
18
  import type { Tool } from "../../tools/types.js";
19
+ import type { Conversation } from "../conversation.js";
19
20
  import { createResolveToolsCallback } from "../conversation-tool-setup.js";
20
21
 
21
- type SkillProjectionContext =
22
- import("../conversation-tool-setup.js").SkillProjectionContext;
23
22
  type SkillProjectionCache =
24
23
  import("../conversation-skill-tools.js").SkillProjectionCache;
25
24
 
@@ -35,15 +34,13 @@ function pluginTool(name: string): Tool {
35
34
  } as unknown as Tool;
36
35
  }
37
36
 
38
- function makeCtx(
39
- overrides: Partial<SkillProjectionContext> = {},
40
- ): SkillProjectionContext {
37
+ function makeCtx(overrides: Partial<Conversation> = {}): Conversation {
41
38
  return {
42
39
  skillProjectionState: new Map(),
43
40
  skillProjectionCache: { fingerprints: new Map() } as SkillProjectionCache,
44
41
  toolsDisabledDepth: 0,
45
42
  ...overrides,
46
- };
43
+ } as unknown as Conversation;
47
44
  }
48
45
 
49
46
  let getConfigSpy: ReturnType<typeof spyOn> | undefined;
@@ -29,6 +29,9 @@
29
29
 
30
30
  import { beforeEach, describe, expect, mock, test } from "bun:test";
31
31
 
32
+ import type { Conversation } from "../conversation.js";
33
+ import type { ChannelCapabilities } from "../conversation-runtime-assembly.js";
34
+
32
35
  // ── Module-level mocks ─────────────────────────────────────────────
33
36
 
34
37
  // Control how many capable clients the hub reports per capability.
@@ -51,20 +54,20 @@ mock.module("../../runtime/assistant-event-hub.js", () => ({
51
54
  // before the modules under test are loaded.
52
55
  const { HOST_TOOL_NAMES, HOST_TOOL_TO_CAPABILITY, isToolActiveForContext } =
53
56
  await import("../conversation-tool-setup.js");
54
- type SkillProjectionContext =
55
- import("../conversation-tool-setup.js").SkillProjectionContext;
56
57
  type SkillProjectionCache =
57
58
  import("../conversation-skill-tools.js").SkillProjectionCache;
58
59
 
59
60
  function makeCtx(
60
- overrides: Partial<SkillProjectionContext> = {},
61
- ): SkillProjectionContext {
61
+ overrides: Partial<Omit<Conversation, "channelCapabilities">> & {
62
+ channelCapabilities?: Partial<ChannelCapabilities>;
63
+ } = {},
64
+ ): Conversation {
62
65
  return {
63
66
  skillProjectionState: new Map(),
64
67
  skillProjectionCache: {} as SkillProjectionCache,
65
68
  toolsDisabledDepth: 0,
66
69
  ...overrides,
67
- };
70
+ } as unknown as Conversation;
68
71
  }
69
72
 
70
73
  beforeEach(() => {
@@ -8,12 +8,10 @@
8
8
 
9
9
  import {
10
10
  type HostProxyCapability,
11
- type InterfaceId,
12
11
  supportsHostProxy,
13
12
  } from "../channels/types.js";
14
13
  import { getIsPlatform } from "../config/env-registry.js";
15
14
  import { getConfig } from "../config/loader.js";
16
- import type { LLMCallSite } from "../config/schemas/llm.js";
17
15
  import type { PermissionPrompter } from "../permissions/prompter.js";
18
16
  import type { SecretPrompter } from "../permissions/secret-prompter.js";
19
17
  import { getBindingByConversation } from "../persistence/external-conversation-store.js";
@@ -58,10 +56,8 @@ import {
58
56
  type UsageAttributionSnapshot,
59
57
  } from "../usage/attribution.js";
60
58
  import { getLogger } from "../util/logger.js";
61
- import {
62
- projectSkillTools,
63
- type SkillProjectionCache,
64
- } from "./conversation-skill-tools.js";
59
+ import type { Conversation } from "./conversation.js";
60
+ import { projectSkillTools } from "./conversation-skill-tools.js";
65
61
  import { surfaceProxyResolver } from "./conversation-surfaces.js";
66
62
  import {
67
63
  isDoordashCommand,
@@ -73,11 +69,7 @@ import { FALLBACK_TURN_TRUST, resolveTrustClass } from "./trust-context.js";
73
69
 
74
70
  const log = getLogger("conversation-tool-setup");
75
71
 
76
- import type {
77
- SubagentToolGateMode,
78
- ToolSetupContext,
79
- WakeToolContextPin,
80
- } from "./tool-setup-types.js";
72
+ import type { ToolSetupContext } from "./tool-setup-types.js";
81
73
  export type {
82
74
  SubagentToolGateMode,
83
75
  ToolSetupContext,
@@ -480,83 +472,6 @@ export function createProxyApprovalCallback(
480
472
  */
481
473
  export const DEFAULT_PREACTIVATED_SKILL_IDS = ["notifications", "subagent"];
482
474
 
483
- /**
484
- * Subset of Conversation state that the resolveTools callback reads at each
485
- * agent turn. Properties are read lazily from this reference.
486
- */
487
- export interface SkillProjectionContext {
488
- preactivatedSkillIds?: string[];
489
- readonly skillProjectionState: Map<string, string>;
490
- readonly skillProjectionCache: SkillProjectionCache;
491
- allowedToolNames?: Set<string>;
492
- /**
493
- * Durable copy of the full tool definitions resolved on the most recent
494
- * turn, used by read-only inventory queries. Set alongside
495
- * {@link allowedToolNames} but, unlike that per-turn execution gate, never
496
- * cleared at turn teardown.
497
- */
498
- registeredToolDefinitions?: ToolDefinition[];
499
- /** When > 0, the resolveTools callback returns no tools at all. */
500
- toolsDisabledDepth: number;
501
- /** Channel capabilities — read lazily per turn for conditional tool filtering. */
502
- readonly channelCapabilities?: {
503
- channel: string;
504
- supportsDynamicUi: boolean;
505
- clientOS?: string;
506
- };
507
- /** True when no client is connected (HTTP-only). */
508
- readonly hasNoClient?: boolean;
509
- /** When set, only tools in this set are included in the resolved tool list (subagent delegation). */
510
- subagentAllowedTools?: Set<string>;
511
- /**
512
- * How {@link subagentAllowedTools} is enforced — see
513
- * {@link SubagentToolGateMode}. Absent means `"wire"`.
514
- */
515
- subagentToolGateMode?: SubagentToolGateMode;
516
- /**
517
- * When set (execution-gate-mode wakes), tool-definition resolution reads
518
- * `hasNoClient` / `transportInterface` / `channelCapabilities` exclusively
519
- * from this pin instead of the live conversation — see
520
- * {@link WakeToolContextPin}.
521
- */
522
- readonly toolContextPin?: WakeToolContextPin;
523
- /** True when the current turn is restricted to disk-pressure cleanup-safe tools. */
524
- diskPressureCleanupModeActive?: boolean;
525
- /** True when this conversation belongs to a subagent spawned by SubagentManager. */
526
- readonly isSubagent?: boolean;
527
- /**
528
- * The interface id of the connected client driving the current turn (e.g.
529
- * "macos", "chrome-extension"). Used to gate host tools by per-capability
530
- * `supportsHostProxy(transport, capability)` so that interfaces which only
531
- * support a subset of the host proxy set (e.g. chrome-extension supports
532
- * `host_browser` but not `host_bash`/`host_file`) do not leak unsupported
533
- * host tools into the LLM tool definitions.
534
- */
535
- readonly transportInterface?: InterfaceId;
536
- /** Per-turn override profile. */
537
- currentTurnOverrideProfile?: string;
538
- /**
539
- * The conversation's per-chat plugin scope (mirrors
540
- * {@link Conversation.enabledPlugins}). `null`/absent means no per-chat
541
- * restriction; otherwise plugin-owned tools and skills are intersected with
542
- * the effective set (the scope unioned with the always-on first-party
543
- * defaults) via {@link getEffectiveEnabledPluginSet}. Read per turn so a
544
- * mid-conversation scope change is picked up.
545
- */
546
- readonly enabledPlugins?: string[] | null;
547
- /**
548
- * Conversation id for `skill_loaded` telemetry. Absent (e.g. minimal test
549
- * contexts) disables telemetry recording in the skill projection.
550
- */
551
- readonly conversationId?: string;
552
- /**
553
- * The LLM call site driving the current turn (see
554
- * {@link ToolSetupContext.currentCallSite}) — read per turn so skill_loaded
555
- * telemetry attributes the provider/model/profile the turn actually ran on.
556
- */
557
- currentCallSite?: LLMCallSite;
558
- }
559
-
560
475
  // ── Conditional tool sets ────────────────────────────────────────────
561
476
 
562
477
  const UI_SURFACE_TOOL_NAMES = new Set(["ui_show", "ui_update", "ui_dismiss"]);
@@ -646,7 +561,7 @@ export const SUBAGENT_ONLY_TOOL_NAMES = new Set<string>([
646
561
  */
647
562
  export function isToolActiveForContext(
648
563
  name: string,
649
- ctx: SkillProjectionContext,
564
+ ctx: Conversation,
650
565
  ): boolean {
651
566
  // Execution-gate-mode wakes pin the client-context inputs so the wire tool
652
567
  // surface matches the SOURCE conversation's live turns rather than the
@@ -780,7 +695,7 @@ export function isToolActiveForContext(
780
695
  */
781
696
  export function createResolveToolsCallback(
782
697
  toolDefs: ToolDefinition[],
783
- ctx: SkillProjectionContext,
698
+ ctx: Conversation,
784
699
  ): ((history: Message[]) => ToolDefinition[]) | undefined {
785
700
  if (toolDefs.length === 0) {
786
701
  return undefined;
@@ -2286,8 +2286,8 @@ export class Conversation {
2286
2286
  }
2287
2287
 
2288
2288
  /**
2289
- * Implements the `transportInterface` field of `SkillProjectionContext` so
2290
- * that `isToolActiveForContext` can gate host tools by per-capability
2289
+ * The `transportInterface` the tool resolver reads so
2290
+ * `isToolActiveForContext` can gate host tools by per-capability
2291
2291
  * `supportsHostProxy(transport, capability)`. Derived from the live turn
2292
2292
  * interface context so it tracks the connected client across turns.
2293
2293
  */
@@ -81,12 +81,21 @@ export interface SearchOptions {
81
81
  cursor?: string;
82
82
  }
83
83
 
84
+ /** A file to attach to an outbound message, with its content already read into memory. */
85
+ export interface OutboundAttachment {
86
+ filename: string;
87
+ mimeType: string;
88
+ data: Buffer;
89
+ }
90
+
84
91
  export interface SendOptions {
85
92
  threadId?: string;
86
93
  /** For email: subject line */
87
94
  subject?: string;
88
95
  /** For email: in-reply-to message ID */
89
96
  inReplyTo?: string;
97
+ /** For email: files to attach. Providers that don't support attachments ignore this. */
98
+ attachments?: OutboundAttachment[];
90
99
  /** Optional assistant scope for multi-assistant channels. */
91
100
  assistantId?: string;
92
101
  }
@@ -19,7 +19,18 @@ import type {
19
19
  SendResult,
20
20
  } from "../../provider-types.js";
21
21
  import * as outlook from "./client.js";
22
- import type { OutlookMessage } from "./types.js";
22
+ import type { OutlookMessage, OutlookSendFileAttachment } from "./types.js";
23
+
24
+ function toGraphAttachments(
25
+ attachments: NonNullable<SendOptions["attachments"]>,
26
+ ): OutlookSendFileAttachment[] {
27
+ return attachments.map((att) => ({
28
+ "@odata.type": "#microsoft.graph.fileAttachment",
29
+ name: att.filename,
30
+ contentType: att.mimeType,
31
+ contentBytes: att.data.toString("base64"),
32
+ }));
33
+ }
23
34
 
24
35
  function requireConnection(
25
36
  connection: OAuthConnection | undefined,
@@ -143,9 +154,12 @@ export const outlookMessagingProvider: MessagingProvider = {
143
154
  options?: SendOptions,
144
155
  ): Promise<SendResult> {
145
156
  const conn = requireConnection(connection);
157
+ const attachments = options?.attachments?.length
158
+ ? toGraphAttachments(options.attachments)
159
+ : undefined;
146
160
 
147
161
  if (options?.inReplyTo) {
148
- await outlook.replyToMessage(conn, options.inReplyTo, text);
162
+ await outlook.replyToMessage(conn, options.inReplyTo, text, attachments);
149
163
  return {
150
164
  id: "",
151
165
  timestamp: Date.now(),
@@ -159,6 +173,7 @@ export const outlookMessagingProvider: MessagingProvider = {
159
173
  subject: options?.subject ?? "",
160
174
  body: { contentType: "text", content: text },
161
175
  toRecipients: [{ emailAddress: { address: conversationId } }],
176
+ ...(attachments ? { attachments } : {}),
162
177
  },
163
178
  });
164
179
 
@@ -17,6 +17,7 @@ import type {
17
17
  OutlookMessageRule,
18
18
  OutlookMessageRuleListResponse,
19
19
  OutlookRecipient,
20
+ OutlookSendFileAttachment,
20
21
  OutlookSendMessagePayload,
21
22
  OutlookUserProfile,
22
23
  } from "./types.js";
@@ -201,18 +202,26 @@ export async function sendMessage(
201
202
  });
202
203
  }
203
204
 
204
- /** Reply to an existing message. */
205
+ /** Reply to an existing message, optionally with file attachments. */
205
206
  export async function replyToMessage(
206
207
  connection: OAuthConnection,
207
208
  messageId: string,
208
209
  comment: string,
210
+ attachments?: OutlookSendFileAttachment[],
209
211
  ): Promise<void> {
212
+ const body: {
213
+ comment: string;
214
+ message?: { attachments: OutlookSendFileAttachment[] };
215
+ } = { comment };
216
+ if (attachments?.length) {
217
+ body.message = { attachments };
218
+ }
210
219
  await request<void>(
211
220
  connection,
212
221
  `/v1.0/me/messages/${encodeURIComponent(messageId)}/reply`,
213
222
  {
214
223
  method: "POST",
215
- body: JSON.stringify({ comment }),
224
+ body: JSON.stringify(body),
216
225
  },
217
226
  );
218
227
  }
@@ -71,6 +71,14 @@ export interface OutlookMailFolderListResponse {
71
71
  "@odata.nextLink"?: string;
72
72
  }
73
73
 
74
+ /** Outbound file attachment for sendMail/reply (base64-encoded content). */
75
+ export interface OutlookSendFileAttachment {
76
+ "@odata.type": "#microsoft.graph.fileAttachment";
77
+ name: string;
78
+ contentType: string;
79
+ contentBytes: string;
80
+ }
81
+
74
82
  /** Payload for sending a message via Microsoft Graph */
75
83
  export interface OutlookSendMessagePayload {
76
84
  message: {
@@ -78,6 +86,7 @@ export interface OutlookSendMessagePayload {
78
86
  body: OutlookItemBody;
79
87
  toRecipients: OutlookRecipient[];
80
88
  ccRecipients?: OutlookRecipient[];
89
+ attachments?: OutlookSendFileAttachment[];
81
90
  };
82
91
  saveToSentItems?: boolean;
83
92
  }
@@ -212,3 +212,40 @@ describe("plugin source watch", () => {
212
212
  expect(versions[join(PLUGINS_DIR, "not-a-plugin")]).toBeUndefined();
213
213
  });
214
214
  });
215
+
216
+ describe("compilePluginApps", () => {
217
+ test("builds only apps that have a src/ entry point", async () => {
218
+ const built: string[] = [];
219
+ const { mock } = await import("bun:test");
220
+ mock.module("../../bundler/app-compiler.js", () => ({
221
+ compileApp: (appDir: string) => {
222
+ built.push(appDir);
223
+ return Promise.resolve({ ok: true });
224
+ },
225
+ }));
226
+
227
+ const { compilePluginApps } = await import("../plugin-source-watch.js");
228
+
229
+ const pluginDir = join(PLUGINS_DIR, "with-apps");
230
+ // Multi-file app (has src/) → built.
231
+ mkdirSync(join(pluginDir, "apps", "multi", "src"), { recursive: true });
232
+ writeFileSync(
233
+ join(pluginDir, "apps", "multi", "src", "main.tsx"),
234
+ "export default 1;",
235
+ );
236
+ // Single-file app (index.html, no src/) → skipped.
237
+ mkdirSync(join(pluginDir, "apps", "single"), { recursive: true });
238
+ writeFileSync(join(pluginDir, "apps", "single", "index.html"), "<div/>");
239
+
240
+ await compilePluginApps(pluginDir);
241
+
242
+ expect(built).toEqual([join(pluginDir, "apps", "multi")]);
243
+ });
244
+
245
+ test("no-ops when the plugin bundles no apps", async () => {
246
+ const { compilePluginApps } = await import("../plugin-source-watch.js");
247
+ const pluginDir = join(PLUGINS_DIR, "no-apps");
248
+ mkdirSync(pluginDir, { recursive: true });
249
+ await expect(compilePluginApps(pluginDir)).resolves.toBeUndefined();
250
+ });
251
+ });