@vellumai/assistant 0.11.5 → 0.11.6-staging.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/AGENTS.md +5 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  4. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  5. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  6. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
  7. package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
  9. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
  10. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
  11. package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
  12. package/openapi.yaml +421 -15
  13. package/package.json +1 -1
  14. package/scripts/sync-web-search-catalog.ts +6 -0
  15. package/src/__tests__/app-pin-store.test.ts +149 -0
  16. package/src/__tests__/channel-availability-routes.test.ts +23 -1
  17. package/src/__tests__/channel-readiness-discord.test.ts +231 -0
  18. package/src/__tests__/channel-readiness-service.test.ts +126 -0
  19. package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
  20. package/src/__tests__/channel-reply-delivery.test.ts +4 -4
  21. package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
  22. package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
  23. package/src/__tests__/conversation-error.test.ts +17 -0
  24. package/src/__tests__/conversation-seed-composer.test.ts +8 -0
  25. package/src/__tests__/conversation-slash-commands.test.ts +8 -0
  26. package/src/__tests__/disk-pressure-policy.test.ts +6 -0
  27. package/src/__tests__/gemini-provider.test.ts +138 -0
  28. package/src/__tests__/history-repair.test.ts +105 -3
  29. package/src/__tests__/identity-routes.test.ts +1 -0
  30. package/src/__tests__/llm-catalog-parity.test.ts +45 -0
  31. package/src/__tests__/migration-import-from-path.test.ts +349 -0
  32. package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
  33. package/src/__tests__/oauth-commands-routes.test.ts +89 -0
  34. package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
  35. package/src/__tests__/openai-provider.test.ts +18 -0
  36. package/src/__tests__/openai-responses-provider.test.ts +18 -0
  37. package/src/__tests__/platform-callback-registration.test.ts +184 -0
  38. package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
  39. package/src/__tests__/pricing.test.ts +2 -2
  40. package/src/__tests__/public-ingress-urls.test.ts +36 -0
  41. package/src/__tests__/resolve-trust-class.test.ts +0 -48
  42. package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
  43. package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
  44. package/src/__tests__/settings-routes.test.ts +85 -3
  45. package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
  46. package/src/agent/history-repair/history-repair.ts +45 -14
  47. package/src/agent/loop.ts +4 -1
  48. package/src/api/constants/profile-config-validation.ts +60 -0
  49. package/src/api/events/tool-result.ts +6 -1
  50. package/src/api/events/watch-retro-completed.ts +52 -0
  51. package/src/api/index.ts +11 -0
  52. package/src/apps/app-pin-reconciler.ts +92 -0
  53. package/src/apps/app-pin-store.ts +125 -0
  54. package/src/channels/gateway-channel-socket-health.ts +32 -0
  55. package/src/channels/gateway-discord-admission.ts +32 -0
  56. package/src/channels/types.ts +20 -0
  57. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
  58. package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
  59. package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
  60. package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
  61. package/src/cli/commands/channels/index.ts +45 -31
  62. package/src/cli/commands/inference-profiles.ts +56 -3
  63. package/src/cli/commands/inference-providers.ts +28 -2
  64. package/src/cli/commands/oauth/index.help.ts +7 -1
  65. package/src/cli/commands/oauth/request.test.ts +290 -0
  66. package/src/cli/commands/oauth/request.ts +57 -41
  67. package/src/cli/lib/bundled-marketplace.json +14 -1
  68. package/src/cli/lib/open-browser.test.ts +67 -0
  69. package/src/cli/lib/open-browser.ts +24 -5
  70. package/src/config/__tests__/profile-materialization.test.ts +26 -0
  71. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
  72. package/src/config/bundled-skills/schedule/SKILL.md +1 -1
  73. package/src/config/feature-flag-registry.json +17 -1
  74. package/src/config/profile-materialization.ts +29 -0
  75. package/src/config/sanitize-for-transfer.ts +16 -0
  76. package/src/config/schemas/llm.ts +7 -0
  77. package/src/config/schemas/services.ts +6 -0
  78. package/src/context/outbound-sanitize.ts +6 -0
  79. package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
  80. package/src/daemon/conversation-error.ts +24 -2
  81. package/src/daemon/conversation-slash.ts +6 -15
  82. package/src/daemon/daemon-control.ts +1 -0
  83. package/src/daemon/disk-pressure-policy.ts +7 -1
  84. package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
  85. package/src/daemon/handlers/config-ingress.ts +115 -5
  86. package/src/daemon/lifecycle.ts +26 -0
  87. package/src/daemon/message-types/web-activity.ts +3 -2
  88. package/src/daemon/trust-context.ts +0 -37
  89. package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
  90. package/src/inbound/platform-callback-registration.ts +28 -2
  91. package/src/inbound/public-ingress-urls.ts +12 -0
  92. package/src/inbound/tunnel-probe.ts +261 -0
  93. package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
  94. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
  95. package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
  97. package/src/live-voice/live-voice-connection.ts +46 -8
  98. package/src/live-voice/live-voice-manager.ts +25 -0
  99. package/src/live-voice/live-voice-session-manager.ts +318 -2
  100. package/src/live-voice/live-voice-session.ts +52 -2
  101. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
  102. package/src/messaging/providers/channel-transport.ts +64 -47
  103. package/src/messaging/providers/discord/send.test.ts +46 -1
  104. package/src/messaging/providers/discord/send.ts +51 -0
  105. package/src/messaging/providers/discord/transport.ts +26 -3
  106. package/src/messaging/providers/index.ts +22 -47
  107. package/src/messaging/providers/slack/send.test.ts +83 -26
  108. package/src/messaging/providers/slack/send.ts +120 -51
  109. package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
  110. package/src/messaging/providers/slack/stream-tasks.ts +39 -0
  111. package/src/messaging/providers/slack/transport.ts +24 -22
  112. package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
  113. package/src/messaging/providers/telegram-bot/send.ts +43 -0
  114. package/src/messaging/providers/telegram-bot/transport.ts +25 -8
  115. package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
  116. package/src/notifications/adapters/telegram.ts +48 -1
  117. package/src/notifications/assistant-reply-producer.ts +7 -7
  118. package/src/notifications/conversation-seed-composer.ts +7 -2
  119. package/src/oauth/byo-connection.test.ts +63 -0
  120. package/src/oauth/byo-connection.ts +16 -15
  121. package/src/oauth/connection.test.ts +111 -0
  122. package/src/oauth/connection.ts +142 -1
  123. package/src/oauth/platform-connection.test.ts +34 -0
  124. package/src/oauth/platform-connection.ts +28 -5
  125. package/src/oauth/seed-providers.ts +15 -1
  126. package/src/permissions/types.ts +3 -1
  127. package/src/persistence/conversation-crud.ts +46 -0
  128. package/src/persistence/conversation-types.ts +11 -9
  129. package/src/persistence/db-async-query.ts +2 -1
  130. package/src/persistence/db-maintenance.ts +15 -0
  131. package/src/persistence/embeddings/qdrant-manager.ts +1 -0
  132. package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
  133. package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
  134. package/src/persistence/migrations/369-create-app-pins.ts +37 -0
  135. package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
  136. package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
  137. package/src/persistence/schema/index.ts +1 -0
  138. package/src/persistence/schema/infrastructure.ts +17 -0
  139. package/src/persistence/schema/watch.ts +29 -0
  140. package/src/persistence/steps.ts +6 -0
  141. package/src/plugins/mtime-cache.ts +11 -0
  142. package/src/providers/__tests__/retry-network-error.test.ts +84 -0
  143. package/src/providers/connection-resolution.ts +23 -1
  144. package/src/providers/content-blocks.ts +9 -0
  145. package/src/providers/fetch-provider-catalog.ts +19 -0
  146. package/src/providers/gemini/client.ts +13 -5
  147. package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
  148. package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
  149. package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
  150. package/src/providers/inference/adapter-factory.ts +0 -9
  151. package/src/providers/inference/credential-rotation.ts +61 -0
  152. package/src/providers/inference/endpoint-probe.ts +115 -0
  153. package/src/providers/inference/profile-probe.ts +256 -0
  154. package/src/providers/model-catalog.ts +170 -125
  155. package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
  156. package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
  157. package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
  158. package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
  159. package/src/providers/openai/api-error-normalization.ts +16 -2
  160. package/src/providers/openai/chat-completions-provider.ts +75 -29
  161. package/src/providers/openai/responses-provider.ts +5 -2
  162. package/src/providers/openrouter/client.ts +0 -1
  163. package/src/providers/provider-send-message.ts +11 -0
  164. package/src/providers/retry.ts +6 -0
  165. package/src/providers/search-provider-catalog.ts +20 -0
  166. package/src/providers/vercel-ai-gateway/client.ts +0 -1
  167. package/src/runtime/AGENTS.md +1 -0
  168. package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
  169. package/src/runtime/__tests__/host-observe.test.ts +302 -0
  170. package/src/runtime/channel-readiness-service.ts +214 -14
  171. package/src/runtime/channel-readiness-types.ts +49 -2
  172. package/src/runtime/channel-reply-delivery.ts +2 -2
  173. package/src/runtime/desktop-presence.ts +24 -21
  174. package/src/runtime/host-observe.ts +246 -0
  175. package/src/runtime/http-server.ts +181 -1
  176. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
  177. package/src/runtime/migrations/staged-import-path.ts +116 -0
  178. package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
  179. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
  180. package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
  181. package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
  182. package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
  183. package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
  184. package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
  185. package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
  186. package/src/runtime/routes/app-management-routes.ts +140 -29
  187. package/src/runtime/routes/channel-availability-routes.ts +1 -0
  188. package/src/runtime/routes/channel-readiness-routes.ts +14 -2
  189. package/src/runtime/routes/conversation-query-routes.ts +10 -0
  190. package/src/runtime/routes/guardian-approval-interception.ts +24 -33
  191. package/src/runtime/routes/host-cu-routes.ts +18 -0
  192. package/src/runtime/routes/identity-routes.ts +2 -0
  193. package/src/runtime/routes/inbound-message-handler.ts +10 -7
  194. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
  195. package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
  196. package/src/runtime/routes/index.ts +2 -0
  197. package/src/runtime/routes/inference-profiles-routes.ts +232 -31
  198. package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
  199. package/src/runtime/routes/ingress-status-routes.ts +180 -0
  200. package/src/runtime/routes/live-voice-routes.test.ts +40 -1
  201. package/src/runtime/routes/live-voice-routes.ts +34 -0
  202. package/src/runtime/routes/migration-routes.ts +218 -10
  203. package/src/runtime/routes/oauth-commands-routes.ts +23 -16
  204. package/src/runtime/routes/plugins-routes.ts +12 -28
  205. package/src/runtime/routes/question-routes.ts +6 -0
  206. package/src/runtime/routes/secret-routes.ts +7 -27
  207. package/src/runtime/routes/settings-routes.ts +9 -6
  208. package/src/runtime/routes/watch-routes.ts +807 -0
  209. package/src/runtime/slack-reply-session.test.ts +230 -121
  210. package/src/runtime/slack-reply-session.ts +113 -81
  211. package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
  212. package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
  213. package/src/security/__tests__/untrusted-content.test.ts +42 -0
  214. package/src/security/untrusted-content.ts +28 -9
  215. package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
  216. package/src/telemetry/live-voice-funnel.ts +75 -8
  217. package/src/tools/credentials/store.ts +18 -6
  218. package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
  219. package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
  220. package/src/tools/network/__tests__/web-search.test.ts +97 -2
  221. package/src/tools/network/firecrawl-compat.ts +90 -0
  222. package/src/tools/network/web-fetch.ts +142 -62
  223. package/src/tools/network/web-search.ts +141 -55
  224. package/src/tools/types.ts +2 -1
  225. package/src/util/oauth-request-body.test.ts +74 -0
  226. package/src/util/oauth-request-body.ts +60 -0
  227. package/src/util/worker-process.ts +1 -0
  228. package/src/watch/__tests__/watch-retro.test.ts +665 -0
  229. package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
  230. package/src/watch/__tests__/watch-timeline.test.ts +670 -0
  231. package/src/watch/watch-retro.ts +480 -0
  232. package/src/watch/watch-session-manager.ts +575 -0
  233. package/src/watch/watch-timeline.ts +848 -0
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ decodeJsonSafeOAuthBody,
5
+ decodeOAuthResponseBytes,
6
+ jsonSafeOAuthBody,
7
+ materializeOAuthRequestOutput,
8
+ } from "./connection.js";
9
+
10
+ const PNG_MAGIC = Buffer.from([
11
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00,
12
+ ]);
13
+
14
+ describe("decodeOAuthResponseBytes", () => {
15
+ test("parses JSON bodies", () => {
16
+ const raw = Buffer.from(JSON.stringify({ id: "file-123", name: "Doc" }));
17
+ expect(
18
+ decodeOAuthResponseBytes(raw, "application/json; charset=utf-8"),
19
+ ).toEqual({ id: "file-123", name: "Doc" });
20
+ });
21
+
22
+ test("returns UTF-8 text for text content types", () => {
23
+ const raw = Buffer.from("hello café", "utf8");
24
+ expect(decodeOAuthResponseBytes(raw, "text/plain; charset=utf-8")).toBe(
25
+ "hello café",
26
+ );
27
+ });
28
+
29
+ test("preserves non-ASCII binary bytes instead of UTF-8 replacement characters", () => {
30
+ const body = decodeOAuthResponseBytes(
31
+ PNG_MAGIC,
32
+ "application/octet-stream",
33
+ );
34
+ expect(Buffer.isBuffer(body)).toBe(true);
35
+ expect(Buffer.from(body as Uint8Array).equals(PNG_MAGIC)).toBe(true);
36
+ expect(Buffer.from(body as Uint8Array).includes(0xff)).toBe(true);
37
+ });
38
+
39
+ test("treats Google Drive media downloads as raw bytes", () => {
40
+ const body = decodeOAuthResponseBytes(
41
+ PNG_MAGIC,
42
+ "application/pdf",
43
+ );
44
+ expect(Buffer.from(body as Uint8Array).equals(PNG_MAGIC)).toBe(true);
45
+ });
46
+
47
+ test("returns null for an empty body", () => {
48
+ expect(decodeOAuthResponseBytes(Buffer.alloc(0), "application/json")).toBe(
49
+ null,
50
+ );
51
+ });
52
+ });
53
+
54
+ describe("jsonSafeOAuthBody", () => {
55
+ test("leaves JSON and text bodies unchanged", () => {
56
+ expect(jsonSafeOAuthBody({ ok: true })).toEqual({ body: { ok: true } });
57
+ expect(jsonSafeOAuthBody("hello")).toEqual({ body: "hello" });
58
+ });
59
+
60
+ test("base64-encodes binary buffers for JSON envelopes", () => {
61
+ expect(jsonSafeOAuthBody(PNG_MAGIC)).toEqual({
62
+ body: PNG_MAGIC.toString("base64"),
63
+ bodyEncoding: "base64",
64
+ });
65
+ expect(Buffer.from(PNG_MAGIC.toString("base64"), "base64").equals(PNG_MAGIC)).toBe(
66
+ true,
67
+ );
68
+ });
69
+ });
70
+
71
+ describe("decodeJsonSafeOAuthBody", () => {
72
+ test("round-trips a binary envelope back to the original bytes", () => {
73
+ const encoded = jsonSafeOAuthBody(PNG_MAGIC);
74
+ const decoded = decodeJsonSafeOAuthBody(encoded);
75
+ expect(Buffer.isBuffer(decoded)).toBe(true);
76
+ expect(Buffer.from(decoded as Uint8Array).equals(PNG_MAGIC)).toBe(true);
77
+ });
78
+
79
+ test("leaves JSON and text envelopes unchanged", () => {
80
+ expect(decodeJsonSafeOAuthBody({ body: { ok: true } })).toEqual({
81
+ ok: true,
82
+ });
83
+ expect(decodeJsonSafeOAuthBody({ body: "hello" })).toBe("hello");
84
+ });
85
+
86
+ test("rejects a base64 marker whose body is not a string", () => {
87
+ expect(() =>
88
+ decodeJsonSafeOAuthBody({ body: { ok: true }, bodyEncoding: "base64" }),
89
+ ).toThrow("bodyEncoding=base64");
90
+ });
91
+ });
92
+
93
+ describe("materializeOAuthRequestOutput", () => {
94
+ test("writes raw bytes for a base64-marked envelope", () => {
95
+ const output = materializeOAuthRequestOutput(jsonSafeOAuthBody(PNG_MAGIC));
96
+ expect(output?.isBinary).toBe(true);
97
+ expect(output?.bytes.equals(PNG_MAGIC)).toBe(true);
98
+ });
99
+
100
+ test("pretty-prints parsed JSON and marks it as text", () => {
101
+ const output = materializeOAuthRequestOutput({ body: { ok: true } });
102
+ expect(output?.isBinary).toBe(false);
103
+ expect(output?.bytes.toString("utf8")).toBe(
104
+ JSON.stringify({ ok: true }, null, 2),
105
+ );
106
+ });
107
+
108
+ test("returns null for a missing body", () => {
109
+ expect(materializeOAuthRequestOutput({ body: null })).toBeNull();
110
+ });
111
+ });
@@ -3,7 +3,12 @@ export interface OAuthConnectionRequest {
3
3
  path: string; // relative, e.g. "/2/tweets"
4
4
  query?: Record<string, string | string[]>;
5
5
  headers?: Record<string, string>;
6
- body?: unknown; // JSON-serializable
6
+ /**
7
+ * A string is forwarded to the provider verbatim under the caller's own
8
+ * `Content-Type` (multipart, XML, form-encoded). Anything else is
9
+ * JSON-serialized and sent as `application/json`.
10
+ */
11
+ body?: unknown;
7
12
  /**
8
13
  * Override the connection's default base URL for this request.
9
14
  * Required for providers that span multiple API hosts sharing
@@ -18,7 +23,143 @@ export interface OAuthConnectionRequest {
18
23
  export interface OAuthConnectionResponse {
19
24
  status: number;
20
25
  headers: Record<string, string>;
26
+ /** JSON, UTF-8 text, or a Buffer of raw bytes for binary payloads. */
27
+ body: unknown;
28
+ }
29
+
30
+ const TEXT_MEDIA_TYPES = new Set([
31
+ "application/json",
32
+ "application/ld+json",
33
+ "application/xml",
34
+ "text/xml",
35
+ "application/javascript",
36
+ "application/x-www-form-urlencoded",
37
+ "application/problem+json",
38
+ "application/xhtml+xml",
39
+ "application/graphql",
40
+ "application/graphql+json",
41
+ ]);
42
+
43
+ function mediaTypeOf(contentType: string): string {
44
+ return contentType.split(";", 1)[0].trim().toLowerCase();
45
+ }
46
+
47
+ function isTextMediaType(mediaType: string): boolean {
48
+ if (!mediaType) {
49
+ return false;
50
+ }
51
+ if (mediaType.startsWith("text/")) {
52
+ return true;
53
+ }
54
+ if (TEXT_MEDIA_TYPES.has(mediaType)) {
55
+ return true;
56
+ }
57
+ return mediaType.endsWith("+json") || mediaType.endsWith("+xml");
58
+ }
59
+
60
+ /**
61
+ * Decode an HTTP body into JSON, UTF-8 text, or a raw Buffer.
62
+ *
63
+ * Binary payloads (Google Drive `?alt=media`, images, PDFs) stay as bytes.
64
+ * JSON and text content types keep their existing parsed/string behavior.
65
+ */
66
+ export function decodeOAuthResponseBytes(
67
+ raw: Uint8Array,
68
+ contentType: string,
69
+ ): unknown {
70
+ if (raw.byteLength === 0) {
71
+ return null;
72
+ }
73
+
74
+ const mediaType = mediaTypeOf(contentType);
75
+ let utf8: string | undefined;
76
+ try {
77
+ utf8 = new TextDecoder("utf-8", { fatal: true }).decode(raw);
78
+ } catch {
79
+ utf8 = undefined;
80
+ }
81
+
82
+ if (utf8 !== undefined) {
83
+ try {
84
+ return JSON.parse(utf8) as unknown;
85
+ } catch {
86
+ if (!mediaType || isTextMediaType(mediaType)) {
87
+ return utf8;
88
+ }
89
+ }
90
+ }
91
+
92
+ if (isTextMediaType(mediaType)) {
93
+ return new TextDecoder("utf-8").decode(raw);
94
+ }
95
+
96
+ return Buffer.from(raw);
97
+ }
98
+
99
+ export function isBinaryOAuthBody(body: unknown): body is Uint8Array {
100
+ return body instanceof Uint8Array;
101
+ }
102
+
103
+ /**
104
+ * JSON-safe form of an OAuth response body. Binary buffers become a base64
105
+ * string plus `bodyEncoding: "base64"` so CLI/HTTP JSON envelopes can round
106
+ * trip the original bytes.
107
+ */
108
+ export function jsonSafeOAuthBody(body: unknown): {
109
+ body: unknown;
110
+ bodyEncoding?: "base64";
111
+ } {
112
+ if (isBinaryOAuthBody(body)) {
113
+ return {
114
+ body: Buffer.from(body).toString("base64"),
115
+ bodyEncoding: "base64",
116
+ };
117
+ }
118
+ return { body };
119
+ }
120
+
121
+ /**
122
+ * Inverse of {@link jsonSafeOAuthBody}. Restores a `bodyEncoding: "base64"`
123
+ * envelope to a Buffer so callers can write the original bytes.
124
+ */
125
+ export function decodeJsonSafeOAuthBody(envelope: {
126
+ body: unknown;
127
+ bodyEncoding?: string | null;
128
+ }): unknown {
129
+ if (envelope.bodyEncoding === "base64") {
130
+ if (typeof envelope.body !== "string") {
131
+ throw new Error(
132
+ "OAuth response marked bodyEncoding=base64 but body is not a string",
133
+ );
134
+ }
135
+ return Buffer.from(envelope.body, "base64");
136
+ }
137
+ return envelope.body;
138
+ }
139
+
140
+ /**
141
+ * Bytes to write for a CLI `oauth request` body. Binary envelopes become
142
+ * raw bytes. Text stays UTF-8. Parsed JSON is pretty-printed. Null bodies
143
+ * produce no output.
144
+ */
145
+ export function materializeOAuthRequestOutput(envelope: {
21
146
  body: unknown;
147
+ bodyEncoding?: string | null;
148
+ }): { bytes: Buffer; isBinary: boolean } | null {
149
+ const decoded = decodeJsonSafeOAuthBody(envelope);
150
+ if (decoded == null) {
151
+ return null;
152
+ }
153
+ if (isBinaryOAuthBody(decoded)) {
154
+ return { bytes: Buffer.from(decoded), isBinary: true };
155
+ }
156
+ if (typeof decoded === "string") {
157
+ return { bytes: Buffer.from(decoded, "utf8"), isBinary: false };
158
+ }
159
+ return {
160
+ bytes: Buffer.from(JSON.stringify(decoded, null, 2), "utf8"),
161
+ isBinary: false,
162
+ };
22
163
  }
23
164
 
24
165
  export interface OAuthConnection {
@@ -102,6 +102,40 @@ describe("PlatformOAuthConnection", () => {
102
102
  expect(result.body).toEqual(upstreamBody);
103
103
  });
104
104
 
105
+ test("decodes base64 binary proxy bodies into a Buffer", async () => {
106
+ const binary = Buffer.from([
107
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00,
108
+ ]);
109
+
110
+ const client = makeMockClient(
111
+ mock(async () => {
112
+ return new Response(
113
+ JSON.stringify({
114
+ status: 200,
115
+ headers: { "Content-Type": "application/octet-stream" },
116
+ body: binary.toString("base64"),
117
+ body_encoding: "base64",
118
+ }),
119
+ { status: 200 },
120
+ );
121
+ }) as unknown as typeof globalThis.fetch,
122
+ );
123
+
124
+ const conn = new PlatformOAuthConnection({
125
+ ...DEFAULT_OPTIONS,
126
+ client,
127
+ });
128
+ const result = await conn.request({
129
+ method: "GET",
130
+ path: "/drive/v3/files/file-123",
131
+ query: { alt: "media" },
132
+ });
133
+
134
+ expect(result.status).toBe(200);
135
+ expect(Buffer.isBuffer(result.body)).toBe(true);
136
+ expect(Buffer.from(result.body as Uint8Array).equals(binary)).toBe(true);
137
+ });
138
+
105
139
  test("forwards per-request baseUrl when provided", async () => {
106
140
  const client = makeMockClient(
107
141
  mock(async (_url: string | URL | Request, init?: RequestInit) => {
@@ -82,6 +82,10 @@ export class PlatformOAuthConnection implements OAuthConnection {
82
82
  async request(req: OAuthConnectionRequest): Promise<OAuthConnectionResponse> {
83
83
  const proxyPath = `/v1/assistants/${this.client.platformAssistantId}/external-provider-proxy/${this.connectionId}/`;
84
84
 
85
+ // The envelope carries the caller's headers and body side by side. A
86
+ // string body is placed in the envelope as a string, so the proxy forwards
87
+ // those bytes verbatim under the caller's Content-Type; an object body
88
+ // travels as JSON and the proxy serializes it.
85
89
  const body: Record<string, unknown> = {
86
90
  request: {
87
91
  method: req.method,
@@ -143,13 +147,10 @@ export class PlatformOAuthConnection implements OAuthConnection {
143
147
  status: number;
144
148
  headers: Record<string, string>;
145
149
  body: unknown;
150
+ body_encoding?: string | null;
146
151
  };
147
152
 
148
- return {
149
- status: json.status,
150
- headers: json.headers,
151
- body: json.body,
152
- };
153
+ return decodePlatformProxyEnvelope(json);
153
154
  }
154
155
 
155
156
  throw new BackendError("Platform proxy request failed after retries");
@@ -161,3 +162,25 @@ export class PlatformOAuthConnection implements OAuthConnection {
161
162
  );
162
163
  }
163
164
  }
165
+
166
+ function decodePlatformProxyEnvelope(json: {
167
+ status: number;
168
+ headers: Record<string, string>;
169
+ body: unknown;
170
+ body_encoding?: string | null;
171
+ }): OAuthConnectionResponse {
172
+ let body = json.body;
173
+ if (json.body_encoding === "base64") {
174
+ if (typeof body !== "string") {
175
+ throw new BackendError(
176
+ "Platform proxy returned body_encoding=base64 without a string body",
177
+ );
178
+ }
179
+ body = Buffer.from(body, "base64");
180
+ }
181
+ return {
182
+ status: json.status,
183
+ headers: json.headers ?? {},
184
+ body,
185
+ };
186
+ }
@@ -92,7 +92,7 @@ export const PROVIDER_SEED_DATA: Record<
92
92
  // base URL override (see GMAIL_API_BASE_URL) for their short paths.
93
93
  baseUrl: "https://www.googleapis.com",
94
94
  displayLabel: "Google",
95
- description: "Gmail, Calendar, Drive, and Contacts",
95
+ description: "Gmail, Calendar, Drive, Docs, Sheets, Slides, and Contacts",
96
96
  dashboardUrl: "https://console.cloud.google.com/apis/credentials",
97
97
  clientIdPlaceholder: "123456789.apps.googleusercontent.com",
98
98
  logoUrl: "https://cdn.simpleicons.org/google",
@@ -137,6 +137,20 @@ export const PROVIDER_SEED_DATA: Record<
137
137
  headerName: "Authorization",
138
138
  valuePrefix: "Bearer ",
139
139
  },
140
+ // The Sheets and Slides APIs accept the auth/drive scope, so the
141
+ // existing token covers them; only the hosts need injection entries.
142
+ {
143
+ hostPattern: "sheets.googleapis.com",
144
+ injectionType: "header",
145
+ headerName: "Authorization",
146
+ valuePrefix: "Bearer ",
147
+ },
148
+ {
149
+ hostPattern: "slides.googleapis.com",
150
+ injectionType: "header",
151
+ headerName: "Authorization",
152
+ valuePrefix: "Bearer ",
153
+ },
140
154
  {
141
155
  hostPattern: "tasks.googleapis.com",
142
156
  injectionType: "header",
@@ -1,3 +1,5 @@
1
+ import type { ChannelConversationType } from "@vellumai/gateway-client";
2
+
1
3
  export type { AllowlistOption, ScopeOption } from "../tools/tool-types.js";
2
4
  export { RiskLevel } from "../tools/tool-types.js";
3
5
 
@@ -79,7 +81,7 @@ export interface PolicyContext {
79
81
  * Conversation type on the permission-matrix axis (dm | private | public),
80
82
  * for the channel-type tier of cell resolution.
81
83
  */
82
- channelConversationType?: string;
84
+ channelConversationType?: ChannelConversationType;
83
85
  /**
84
86
  * Whether procedural-memory-as-skills is active for this assistant (memory-v3
85
87
  * is live). Precomputed in {@link buildPolicyContext} so the checker can gate
@@ -50,6 +50,10 @@ import {
50
50
  isRetryableSqliteError,
51
51
  withSqliteRetry,
52
52
  } from "../util/sqlite-retry.js";
53
+ import {
54
+ purgeAllWatchTimelines,
55
+ purgeWatchTimelineForConversation,
56
+ } from "../watch/watch-timeline.js";
53
57
  import {
54
58
  deleteOrphanAttachments,
55
59
  linkAttachmentToMessage,
@@ -191,6 +195,36 @@ function dedicatedTableExists(db: DrizzleDb, table: string): boolean {
191
195
  * keys on `conversation_id` regardless of event name, so this covers every
192
196
  * conversation-scoped pending event.
193
197
  */
198
+ /**
199
+ * Remove a conversation's watch-session timeline: the narration, the screens,
200
+ * and the frames of them the entries carry.
201
+ *
202
+ * The rows are keyed by conversation id with no cascade behind them, so a
203
+ * delete that skipped them would keep pictures of the user's screen for a
204
+ * conversation they asked to remove. It runs after the row deletes, which is
205
+ * the order the watch store expects: once `conversations` no longer has the
206
+ * row, a later append is refused rather than left for a purge that has already
207
+ * been.
208
+ *
209
+ * Best-effort, like the rest of the post-transaction cleanup. The conversation
210
+ * row is already gone by this point, so throwing here would turn a completed
211
+ * delete into an error the caller cannot usefully retry. A swallowed failure
212
+ * is not the end of the story: `sweepOrphanedWatchTimelineEntries` runs from
213
+ * daemon startup and from database maintenance and deletes entries whose
214
+ * conversation no longer exists, so whatever this pass leaves behind is
215
+ * reclaimed on a later one.
216
+ */
217
+ function purgeWatchTimelineForDeletedConversation(id: string): void {
218
+ try {
219
+ purgeWatchTimelineForConversation(id);
220
+ } catch (err) {
221
+ log.warn(
222
+ { err, conversationId: id },
223
+ "Failed to purge the watch timeline for a deleted conversation",
224
+ );
225
+ }
226
+ }
227
+
194
228
  function deletePendingTelemetryEventsForConversation(id: string): void {
195
229
  const telemetry = getTelemetryDb({ createIfMissing: false });
196
230
  if (!telemetry || !dedicatedTableExists(telemetry, "telemetry_events")) {
@@ -2109,6 +2143,8 @@ export function deleteConversation(id: string): DeletedMemoryIds {
2109
2143
  removeConversationDir(id, createdAtForDiskCleanup);
2110
2144
  }
2111
2145
 
2146
+ purgeWatchTimelineForDeletedConversation(id);
2147
+
2112
2148
  // Notify `conversation-deleted` hooks (e.g. the memory plugin failing its
2113
2149
  // still-pending jobs for this conversation). Fire-and-forget from this
2114
2150
  // synchronous primitive — the pipeline contains per-hook failures, and
@@ -2259,6 +2295,8 @@ export async function deleteConversationGently(
2259
2295
  removeConversationDir(id, createdAtForDiskCleanup);
2260
2296
  }
2261
2297
 
2298
+ purgeWatchTimelineForDeletedConversation(id);
2299
+
2262
2300
  // Notify `conversation-deleted` hooks — fire-and-forget, same contract as
2263
2301
  // the synchronous delete primitive.
2264
2302
  void runHook(HOOKS.CONVERSATION_DELETED, {
@@ -3588,6 +3626,14 @@ export async function clearAll(): Promise<{
3588
3626
  // cascade; wipe them explicitly so labels/objectives don't survive (or
3589
3627
  // rehydrate after) a clear-all.
3590
3628
  await runOrThrow("DELETE FROM subagents");
3629
+ // Watch-session timelines are conversation-keyed rows the cascade does not
3630
+ // reach. They come after `conversations` so this statement is the last thing
3631
+ // that needs to reach one: an append arriving from here on finds no
3632
+ // conversation to key itself to and is refused, and an append that already
3633
+ // ran is a row this deletes. The bulk deletes above run in a sqlite3
3634
+ // subprocess, so the event loop is free while they work; this one is a
3635
+ // synchronous in-process statement, which nothing can interleave with.
3636
+ purgeAllWatchTimelines();
3591
3637
 
3592
3638
  // The memory feature's relocated conversation-keyed tables lost their main-DB
3593
3639
  // cascade. Signal the wipe through the hook rather than reaching into the
@@ -228,21 +228,22 @@ export function isVoiceSessionUserMessage(
228
228
  }
229
229
 
230
230
  /**
231
- * True when the row that opened the turn was sent from the macOS app, on that
231
+ * True when the row that opened the turn was sent from a desktop app, on that
232
232
  * row's own evidence.
233
233
  *
234
234
  * Two markers, both required. The `client` bag's `os` entry is the only
235
235
  * per-platform attribution on a message: `userMessageInterface` is `"web"` for
236
- * the macOS app, the iOS app, and a desktop browser alike. `clientOsFromRequest`
237
- * says that `os` was reported by this row's request or transport rather than
238
- * inherited from the conversation's live client state, which names the surface
239
- * of an earlier turn: a button tapped on the phone against a conversation last
240
- * sent to from the Mac persists `os: "macos"` with no marker.
236
+ * the desktop apps, the iOS app, and a desktop browser alike.
237
+ * `clientOsFromRequest` says that `os` was reported by this row's request or
238
+ * transport rather than inherited from the conversation's live client state,
239
+ * which names the surface of an earlier turn. A button tapped on the phone
240
+ * against a conversation last sent to from desktop persists the old OS with no
241
+ * marker.
241
242
  *
242
243
  * Origin a row did not report itself is origin unknown. Callers gate
243
- * suppression on this, so unknown has to read as not-macOS.
244
+ * suppression on this, so unknown has to read as not-desktop.
244
245
  */
245
- export function isMacOriginatedUserMessage(
246
+ export function isDesktopOriginatedUserMessage(
246
247
  metadata: Record<string, unknown> | undefined,
247
248
  ): boolean {
248
249
  if (metadata?.clientOsFromRequest !== true) {
@@ -252,7 +253,8 @@ export function isMacOriginatedUserMessage(
252
253
  if (typeof client !== "object" || client === null) {
253
254
  return false;
254
255
  }
255
- return parseClientOs((client as Record<string, unknown>).os) === "macos";
256
+ const clientOs = parseClientOs((client as Record<string, unknown>).os);
257
+ return clientOs === "macos" || clientOs === "windows";
256
258
  }
257
259
 
258
260
  /**
@@ -216,6 +216,7 @@ async function runViaCli(
216
216
  // never an issue regardless of the statement complexity.
217
217
  const proc = Bun.spawn({
218
218
  cmd: [sqlite3Path, dbPath],
219
+ windowsHide: true,
219
220
  stdin: "pipe",
220
221
  stdout: "pipe",
221
222
  stderr: "pipe",
@@ -396,7 +397,7 @@ export function spawnDetachedWalCheckpoint(): boolean {
396
397
  getDbPath(),
397
398
  `PRAGMA busy_timeout=${SQLITE_BUSY_TIMEOUT_MS}; PRAGMA wal_checkpoint(TRUNCATE);`,
398
399
  ],
399
- { detached: true, stdio: "ignore" },
400
+ { detached: true, stdio: "ignore", windowsHide: true },
400
401
  );
401
402
  // A post-spawn failure can't be acted on — the daemon is exiting — but an
402
403
  // unlistened ChildProcess "error" event would throw as an uncaught
@@ -3,6 +3,7 @@ import { statSync } from "node:fs";
3
3
  import { getConfig } from "../config/loader.js";
4
4
  import { getLogger } from "../util/logger.js";
5
5
  import { getDbPath } from "../util/platform.js";
6
+ import { sweepOrphanedWatchTimelineEntries } from "../watch/watch-timeline.js";
6
7
  import { pruneRuns } from "../workflows/journal-store.js";
7
8
  import { getMemoryCheckpoint, setMemoryCheckpoint } from "./checkpoints.js";
8
9
  import { getLastInteractiveUserMessageTimestamp } from "./conversation-crud.js";
@@ -81,6 +82,20 @@ async function runDbMaintenance(): Promise<void> {
81
82
  log.warn({ err }, "Workflow run pruning failed (non-fatal)");
82
83
  }
83
84
 
85
+ // Reclaim watch-timeline entries whose conversation is gone. Their purge runs
86
+ // after the conversation row is already deleted and cannot be retried by the
87
+ // caller, and nothing cascades into the table, so a failed purge or a crash
88
+ // between the two writes would otherwise keep screenshots of the user's
89
+ // screen indefinitely. Bounded per pass and best-effort inside the sweep,
90
+ // which reports zero rather than throwing, so it needs no guard here.
91
+ const sweptWatchEntries = sweepOrphanedWatchTimelineEntries();
92
+ if (sweptWatchEntries > 0) {
93
+ log.info(
94
+ { sweptWatchEntries },
95
+ "Swept watch timeline entries for deleted conversations",
96
+ );
97
+ }
98
+
84
99
  // Refresh the query planner's statistics (see PLANNER_OPTIMIZE_PRAGMA for
85
100
  // what the mask buys here). Routed through the async path to keep it off the
86
101
  // main thread when the sqlite3 CLI backend is available.
@@ -154,6 +154,7 @@ export class QdrantManager {
154
154
 
155
155
  const proc = Bun.spawn({
156
156
  cmd: [spawnPath],
157
+ windowsHide: true,
157
158
  env: {
158
159
  ...process.env,
159
160
  QDRANT__SERVICE__HOST: this.host,
@@ -0,0 +1,46 @@
1
+ import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
2
+
3
+ /**
4
+ * Creates `watch_timeline_entries`: the per-session record of what a watch
5
+ * session's user narrated and what was on their screen.
6
+ *
7
+ * The table is the timeline's only home. Entries live outside conversation
8
+ * message history, so nothing here participates in turn alternation or
9
+ * provider-facing sanitization, and only the retrospective's rendered summary
10
+ * reaches a model.
11
+ *
12
+ * `screenshot_attachment_id` holds the id of a row in `attachments`, so the
13
+ * pixels stay on disk behind the attachment store rather than in this table.
14
+ * Timeline rows and the screenshots they own are removed together by the
15
+ * watch store's purge.
16
+ *
17
+ * The two indexes cover the two access paths: reads are scoped to one session
18
+ * and ordered by offset, and purges are scoped to one conversation.
19
+ *
20
+ * Idempotent: `IF NOT EXISTS` on the table and both indexes.
21
+ */
22
+ export function migrateCreateWatchTimelineEntries(db: DrizzleDb): void {
23
+ const raw = getSqliteFrom(db);
24
+
25
+ raw.exec(/*sql*/ `
26
+ CREATE TABLE IF NOT EXISTS watch_timeline_entries (
27
+ id TEXT PRIMARY KEY,
28
+ session_id TEXT NOT NULL,
29
+ conversation_id TEXT NOT NULL,
30
+ at_ms INTEGER NOT NULL,
31
+ kind TEXT NOT NULL,
32
+ text TEXT NOT NULL,
33
+ ax_tree TEXT,
34
+ ax_diff TEXT,
35
+ screenshot_attachment_id TEXT,
36
+ created_at INTEGER NOT NULL
37
+ )
38
+ `);
39
+
40
+ raw.exec(
41
+ `CREATE INDEX IF NOT EXISTS idx_watch_timeline_entries_session ON watch_timeline_entries(session_id, at_ms)`,
42
+ );
43
+ raw.exec(
44
+ `CREATE INDEX IF NOT EXISTS idx_watch_timeline_entries_conversation ON watch_timeline_entries(conversation_id)`,
45
+ );
46
+ }
@@ -0,0 +1,33 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+ import { tableHasColumn } from "./schema-introspection.js";
3
+
4
+ /**
5
+ * Move a watch timeline entry's screenshot into the row that owns it.
6
+ *
7
+ * `screenshot` holds the frame's JPEG bytes directly, so an entry is one row
8
+ * with one lifetime: a single `DELETE` takes the narration, the screen, and
9
+ * the pixels together, with nothing staged elsewhere to coordinate against.
10
+ * `screenshot_attachment_id`, the id of a separately staged file, goes with
11
+ * it.
12
+ *
13
+ * Idempotent: each `ALTER TABLE` is guarded by a column check, so a crash
14
+ * between the two leaves the next boot able to finish the job.
15
+ */
16
+ export function migrateWatchTimelineScreenshotBlob(database: DrizzleDb): void {
17
+ if (!tableHasColumn(database, "watch_timeline_entries", "screenshot")) {
18
+ database.run(
19
+ /*sql*/ `ALTER TABLE watch_timeline_entries ADD COLUMN screenshot BLOB`,
20
+ );
21
+ }
22
+ if (
23
+ tableHasColumn(
24
+ database,
25
+ "watch_timeline_entries",
26
+ "screenshot_attachment_id",
27
+ )
28
+ ) {
29
+ database.run(
30
+ /*sql*/ `ALTER TABLE watch_timeline_entries DROP COLUMN screenshot_attachment_id`,
31
+ );
32
+ }
33
+ }