@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,37 @@
1
+ import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
2
+
3
+ /**
4
+ * Creates `app_pins`: which apps the user pinned to the sidebar.
5
+ *
6
+ * A pin is a preference about an app rather than part of one, so it is keyed by
7
+ * app id here instead of living in the app's own on-disk record. That is what
8
+ * lets a plugin app be pinned: its record belongs to the plugin that ships it
9
+ * and the daemon does not write to it.
10
+ *
11
+ * `sort_position` is a fractional index, the same idiom `conversation_groups`
12
+ * uses, so a pin can be placed between two others by averaging their positions
13
+ * instead of renumbering the list.
14
+ *
15
+ * A row holds no display fields. Name and icon are read from the app at list
16
+ * time, so a renamed app renames its pin and a pin whose app is gone reaches
17
+ * no client.
18
+ *
19
+ * Idempotent: `IF NOT EXISTS` on the table and the index.
20
+ */
21
+ export function migrateCreateAppPins(db: DrizzleDb): void {
22
+ const raw = getSqliteFrom(db);
23
+
24
+ raw.exec(/*sql*/ `
25
+ CREATE TABLE IF NOT EXISTS app_pins (
26
+ app_id TEXT PRIMARY KEY,
27
+ sort_position REAL NOT NULL,
28
+ color TEXT,
29
+ created_at INTEGER NOT NULL
30
+ )
31
+ `);
32
+
33
+ // The only read path: every pin, in sidebar order.
34
+ raw.exec(
35
+ `CREATE INDEX IF NOT EXISTS idx_app_pins_sort_position ON app_pins(sort_position)`,
36
+ );
37
+ }
@@ -0,0 +1,98 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { describe, expect, test } from "bun:test";
3
+
4
+ import { drizzle } from "drizzle-orm/bun-sqlite";
5
+
6
+ import { getSqliteFrom } from "../../db-connection.js";
7
+ import * as schema from "../../schema.js";
8
+ import { migrateCreateWatchTimelineEntries } from "../367-create-watch-timeline-entries.js";
9
+
10
+ function createTestDb() {
11
+ const sqlite = new Database(":memory:");
12
+ sqlite.exec("PRAGMA journal_mode=WAL");
13
+ return drizzle(sqlite, { schema });
14
+ }
15
+
16
+ function columnNames(raw: Database): string[] {
17
+ const rows = raw
18
+ .prepare("PRAGMA table_info(watch_timeline_entries)")
19
+ .all() as Array<{ name: string }>;
20
+ return rows.map((row) => row.name);
21
+ }
22
+
23
+ function indexNames(raw: Database): string[] {
24
+ const rows = raw
25
+ .prepare(
26
+ `SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'watch_timeline_entries' AND sql IS NOT NULL ORDER BY name`,
27
+ )
28
+ .all() as Array<{ name: string }>;
29
+ return rows.map((row) => row.name);
30
+ }
31
+
32
+ function insertEntry(raw: Database, id: string, atMs: number): void {
33
+ raw.run(
34
+ `INSERT INTO watch_timeline_entries
35
+ (id, session_id, conversation_id, at_ms, kind, text, ax_tree, ax_diff, screenshot_attachment_id, created_at)
36
+ VALUES (?, 'sess-1', 'conv-1', ?, 'narration', 'hello', NULL, NULL, NULL, 1000)`,
37
+ [id, atMs],
38
+ );
39
+ }
40
+
41
+ describe("migration 367: create watch_timeline_entries", () => {
42
+ test("creates the table with every timeline column", () => {
43
+ const db = createTestDb();
44
+ const raw = getSqliteFrom(db);
45
+
46
+ migrateCreateWatchTimelineEntries(db);
47
+
48
+ expect(columnNames(raw).sort()).toEqual([
49
+ "at_ms",
50
+ "ax_diff",
51
+ "ax_tree",
52
+ "conversation_id",
53
+ "created_at",
54
+ "id",
55
+ "kind",
56
+ "screenshot_attachment_id",
57
+ "session_id",
58
+ "text",
59
+ ]);
60
+ });
61
+
62
+ test("creates the session and conversation indexes", () => {
63
+ const db = createTestDb();
64
+ const raw = getSqliteFrom(db);
65
+
66
+ migrateCreateWatchTimelineEntries(db);
67
+
68
+ expect(indexNames(raw)).toEqual([
69
+ "idx_watch_timeline_entries_conversation",
70
+ "idx_watch_timeline_entries_session",
71
+ ]);
72
+ });
73
+
74
+ test("is idempotent and preserves existing rows", () => {
75
+ const db = createTestDb();
76
+ const raw = getSqliteFrom(db);
77
+
78
+ migrateCreateWatchTimelineEntries(db);
79
+ insertEntry(raw, "entry-1", 1000);
80
+
81
+ expect(() => migrateCreateWatchTimelineEntries(db)).not.toThrow();
82
+
83
+ const rows = raw
84
+ .prepare("SELECT id, at_ms FROM watch_timeline_entries")
85
+ .all() as Array<{ id: string; at_ms: number }>;
86
+ expect(rows).toEqual([{ id: "entry-1", at_ms: 1000 }]);
87
+ });
88
+
89
+ test("rejects a duplicate entry id", () => {
90
+ const db = createTestDb();
91
+ const raw = getSqliteFrom(db);
92
+
93
+ migrateCreateWatchTimelineEntries(db);
94
+ insertEntry(raw, "entry-1", 1000);
95
+
96
+ expect(() => insertEntry(raw, "entry-1", 2000)).toThrow();
97
+ });
98
+ });
@@ -0,0 +1,98 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { describe, expect, test } from "bun:test";
3
+
4
+ import { drizzle } from "drizzle-orm/bun-sqlite";
5
+
6
+ import { getSqliteFrom } from "../../db-connection.js";
7
+ import * as schema from "../../schema.js";
8
+ import { migrateCreateWatchTimelineEntries } from "../367-create-watch-timeline-entries.js";
9
+ import { migrateWatchTimelineScreenshotBlob } from "../368-watch-timeline-screenshot-blob.js";
10
+
11
+ function createTestDb() {
12
+ const sqlite = new Database(":memory:");
13
+ sqlite.exec("PRAGMA journal_mode=WAL");
14
+ const db = drizzle(sqlite, { schema });
15
+ migrateCreateWatchTimelineEntries(db);
16
+ return db;
17
+ }
18
+
19
+ function columnNames(raw: Database): string[] {
20
+ const rows = raw
21
+ .prepare("PRAGMA table_info(watch_timeline_entries)")
22
+ .all() as Array<{ name: string }>;
23
+ return rows.map((row) => row.name).sort();
24
+ }
25
+
26
+ function insertEntry(raw: Database, id: string, screenshot: Buffer | null) {
27
+ raw.run(
28
+ `INSERT INTO watch_timeline_entries
29
+ (id, session_id, conversation_id, at_ms, kind, text, ax_tree, ax_diff, screenshot, created_at)
30
+ VALUES (?, 'sess-1', 'conv-1', 1000, 'observation', '', NULL, NULL, ?, 1000)`,
31
+ [id, screenshot],
32
+ );
33
+ }
34
+
35
+ describe("migration 368: watch timeline screenshot blob", () => {
36
+ test("adds the blob column and drops the attachment id", () => {
37
+ const db = createTestDb();
38
+ const raw = getSqliteFrom(db);
39
+
40
+ migrateWatchTimelineScreenshotBlob(db);
41
+
42
+ expect(columnNames(raw)).toEqual([
43
+ "at_ms",
44
+ "ax_diff",
45
+ "ax_tree",
46
+ "conversation_id",
47
+ "created_at",
48
+ "id",
49
+ "kind",
50
+ "screenshot",
51
+ "session_id",
52
+ "text",
53
+ ]);
54
+ });
55
+
56
+ test("stores frame bytes in the row and reads them back whole", () => {
57
+ const db = createTestDb();
58
+ const raw = getSqliteFrom(db);
59
+ migrateWatchTimelineScreenshotBlob(db);
60
+
61
+ const frame = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x10]);
62
+ insertEntry(raw, "entry-1", frame);
63
+ insertEntry(raw, "entry-2", null);
64
+
65
+ const rows = raw
66
+ .prepare(
67
+ "SELECT id, screenshot, length(screenshot) AS bytes FROM watch_timeline_entries ORDER BY id",
68
+ )
69
+ .all() as Array<{
70
+ id: string;
71
+ screenshot: unknown;
72
+ bytes: number | null;
73
+ }>;
74
+
75
+ expect(rows.map((row) => row.bytes)).toEqual([frame.length, null]);
76
+ expect(Buffer.from(rows[0]?.screenshot as Uint8Array)).toEqual(frame);
77
+ });
78
+
79
+ test("is idempotent and preserves existing rows", () => {
80
+ const db = createTestDb();
81
+ const raw = getSqliteFrom(db);
82
+
83
+ migrateWatchTimelineScreenshotBlob(db);
84
+ const frame = Buffer.from([1, 2, 3]);
85
+ insertEntry(raw, "entry-1", frame);
86
+
87
+ expect(() => migrateWatchTimelineScreenshotBlob(db)).not.toThrow();
88
+
89
+ const rows = raw
90
+ .prepare(
91
+ "SELECT id, length(screenshot) AS bytes FROM watch_timeline_entries",
92
+ )
93
+ .all() as Array<{ id: string; bytes: number }>;
94
+ expect(rows).toEqual([{ id: "entry-1", bytes: frame.length }]);
95
+ expect(columnNames(raw)).toContain("screenshot");
96
+ expect(columnNames(raw)).not.toContain("screenshot_attachment_id");
97
+ });
98
+ });
@@ -16,4 +16,5 @@ export * from "./memory-injection.js";
16
16
  export * from "./notifications.js";
17
17
  export * from "./oauth.js";
18
18
  export * from "./tasks.js";
19
+ export * from "./watch.js";
19
20
  export * from "./workflows.js";
@@ -91,6 +91,23 @@ export const sharedAppLinks = sqliteTable("shared_app_links", {
91
91
  expiresAt: integer("expires_at"),
92
92
  });
93
93
 
94
+ /**
95
+ * Which apps the user pinned to the sidebar. A pin is a preference about an
96
+ * app rather than part of one, so it lives here rather than in the app's own
97
+ * record: plugin apps are pinnable and their records belong to the plugin that
98
+ * ships them.
99
+ *
100
+ * `sortPosition` is a fractional index, matching `conversation_groups`, so a
101
+ * pin can be placed between two others without renumbering the rest.
102
+ */
103
+ export const appPins = sqliteTable("app_pins", {
104
+ appId: text("app_id").primaryKey(),
105
+ sortPosition: real("sort_position").notNull(),
106
+ /** An id from the client's pinned-app colour registry; null = uncoloured. */
107
+ color: text("color"),
108
+ createdAt: integer("created_at").notNull(),
109
+ });
110
+
94
111
  export const publishedPages = sqliteTable("published_pages", {
95
112
  id: text("id").primaryKey(),
96
113
  deploymentId: text("deployment_id").notNull().unique(),
@@ -0,0 +1,29 @@
1
+ import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+
3
+ /**
4
+ * One entry in a watch session's timeline: what the user narrated, or what
5
+ * was on their screen while they narrated it.
6
+ *
7
+ * The timeline is not conversation history. Entries are keyed by session and
8
+ * ordered by `atMs`, the offset from the session's start, and only a rendered
9
+ * summary reaches a model, composed by the retrospective that reads them.
10
+ *
11
+ * `screenshot` holds the frame itself, so the row is an entry's only home: one
12
+ * row, one lifetime, one delete. Reads that do not want the pixels select the
13
+ * other columns and leave this one alone.
14
+ */
15
+ export const watchTimelineEntries = sqliteTable("watch_timeline_entries", {
16
+ id: text("id").primaryKey(),
17
+ sessionId: text("session_id").notNull(),
18
+ conversationId: text("conversation_id").notNull(),
19
+ /** Milliseconds since the session started. */
20
+ atMs: integer("at_ms").notNull(),
21
+ /** `narration` or `observation`. */
22
+ kind: text("kind").notNull(),
23
+ text: text("text").notNull(),
24
+ axTree: text("ax_tree"),
25
+ axDiff: text("ax_diff"),
26
+ /** JPEG bytes of the screen at `atMs`, or NULL when none was kept. */
27
+ screenshot: blob("screenshot", { mode: "buffer" }),
28
+ createdAt: integer("created_at").notNull(),
29
+ });
@@ -474,6 +474,9 @@ import { migrateBackfillScheduleInferenceProfile } from "./migrations/363-backfi
474
474
  import { migrateAddScheduleSourceKey } from "./migrations/364-add-schedule-source-key.js";
475
475
  import { migrateAddConversationForkStrategy } from "./migrations/365-add-conversation-fork-strategy.js";
476
476
  import { migrateChatgptSubscriptionRowIdentity } from "./migrations/366-chatgpt-subscription-row-identity.js";
477
+ import { migrateCreateWatchTimelineEntries } from "./migrations/367-create-watch-timeline-entries.js";
478
+ import { migrateWatchTimelineScreenshotBlob } from "./migrations/368-watch-timeline-screenshot-blob.js";
479
+ import { migrateCreateAppPins } from "./migrations/369-create-app-pins.js";
477
480
  import type { MigrationStep } from "./migrations/run-migrations.js";
478
481
 
479
482
  export const migrationSteps: MigrationStep[] = [
@@ -1582,4 +1585,7 @@ export const migrationSteps: MigrationStep[] = [
1582
1585
  // permanently checkpoint the no-op.
1583
1586
  dependsOn: ["migrateCreateProviderConnections"],
1584
1587
  },
1588
+ migrateCreateWatchTimelineEntries,
1589
+ migrateWatchTimelineScreenshotBlob,
1590
+ migrateCreateAppPins,
1585
1591
  ];
@@ -291,6 +291,17 @@ export async function reconcilePluginSourcesNow(): Promise<void> {
291
291
  } catch (err) {
292
292
  log.error({ err }, "plugin MCP reconcile failed");
293
293
  }
294
+ // Converge sidebar pins the same way: an uninstalled plugin's app stops
295
+ // existing, and its id is a stable path identity, so a pin left behind
296
+ // would return to the sidebar when the plugin does. Lazily imported and
297
+ // self-contained for the same reasons as the two reconciles above.
298
+ try {
299
+ const { reconcileAppPins } =
300
+ await import("../apps/app-pin-reconciler.js");
301
+ reconcileAppPins();
302
+ } catch (err) {
303
+ log.error({ err }, "app pin reconcile failed");
304
+ }
294
305
  })().finally(() => {
295
306
  reconcileInFlight = null;
296
307
  });
@@ -0,0 +1,84 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ import * as retryUtil from "../../util/retry.js";
4
+
5
+ // Instant sleep so backoff delays don't slow the suite; everything else is
6
+ // the real module.
7
+ mock.module("../../util/retry.js", () => ({
8
+ ...retryUtil,
9
+ sleep: async () => {},
10
+ }));
11
+
12
+ const { RetryProvider } = await import("../retry.js");
13
+ const { ProviderError } = await import("../../util/errors.js");
14
+
15
+ import type { Message, Provider, ProviderResponse } from "../types.js";
16
+
17
+ const OK_RESPONSE = {
18
+ content: [{ type: "text", text: "ok" }],
19
+ model: "test-model",
20
+ usage: { inputTokens: 1, outputTokens: 1 },
21
+ } as unknown as ProviderResponse;
22
+
23
+ const MESSAGES: Message[] = [
24
+ { role: "user", content: [{ type: "text", text: "hi" }] },
25
+ ];
26
+
27
+ /** Inner provider that throws the queued errors in order, then succeeds. */
28
+ function flakyProvider(errors: unknown[]): {
29
+ provider: Provider;
30
+ calls: () => number;
31
+ } {
32
+ let calls = 0;
33
+ const queue = [...errors];
34
+ const provider: Provider = {
35
+ name: "openai-compatible",
36
+ sendMessage: async () => {
37
+ calls += 1;
38
+ const next = queue.shift();
39
+ if (next !== undefined) {
40
+ throw next;
41
+ }
42
+ return OK_RESPONSE;
43
+ },
44
+ };
45
+ return { provider, calls: () => calls };
46
+ }
47
+
48
+ describe("RetryProvider network_error handling", () => {
49
+ test("retries a transport failure stamped with reason network_error", async () => {
50
+ const { provider, calls } = flakyProvider([
51
+ new ProviderError(
52
+ "OpenAI-compatible API error (unknown status): Connection error.",
53
+ "openai-compatible",
54
+ undefined,
55
+ { reason: "network_error" },
56
+ ),
57
+ ]);
58
+ const result = await new RetryProvider(provider).sendMessage(MESSAGES);
59
+ expect(result).toBe(OK_RESPONSE);
60
+ expect(calls()).toBe(2);
61
+ });
62
+
63
+ test("still does not retry a statusless reason-less abort-shaped error", async () => {
64
+ // OpenAI catch-sites do not distinguish inner stream deadlines from
65
+ // transport aborts, so their "Request was aborted." must stay
66
+ // non-retryable (see RETRYABLE_TRANSPORT_ABORT_PATTERNS).
67
+ const abortShaped = () =>
68
+ new ProviderError(
69
+ "OpenAI API error (unknown status): Request was aborted.",
70
+ "openai",
71
+ undefined,
72
+ );
73
+ const { provider, calls } = flakyProvider([
74
+ abortShaped(),
75
+ abortShaped(),
76
+ abortShaped(),
77
+ abortShaped(),
78
+ ]);
79
+ await expect(
80
+ new RetryProvider(provider).sendMessage(MESSAGES),
81
+ ).rejects.toThrow("Request was aborted");
82
+ expect(calls()).toBe(1);
83
+ });
84
+ });
@@ -41,7 +41,10 @@ import {
41
41
  describeSubscriptionModelIncompatibility,
42
42
  isConnectionCompatibleWithModel,
43
43
  } from "./connection-model-compat.js";
44
- import { VALID_CONNECTION_PROVIDERS } from "./inference/auth.js";
44
+ import {
45
+ ROUTING_IDENTITY_PROVIDERS,
46
+ VALID_CONNECTION_PROVIDERS,
47
+ } from "./inference/auth.js";
45
48
  import {
46
49
  canonicalVellumConnection,
47
50
  getConnection,
@@ -193,6 +196,25 @@ export function resolveEntryProviderKind(
193
196
  return entryName ? connectionProviderKind(entryName, model) : null;
194
197
  }
195
198
 
199
+ /**
200
+ * The catalog provider whose model limits judge a profile's (provider,
201
+ * model) pair: routing identities translate to their concrete upstream
202
+ * (chatgpt serves the OpenAI catalog; a vellum profile's bare native model
203
+ * names its managed owner), and an entry-name label resolves to its row's
204
+ * dispatchable kind. Null when no upstream is derivable (the pair stays
205
+ * unjudged). Shared by the profile-route budget validation and profile
206
+ * materialization so the two cannot drift.
207
+ */
208
+ export function catalogProviderForProfile(
209
+ provider: string,
210
+ model: string,
211
+ ): string | null {
212
+ if (ROUTING_IDENTITY_PROVIDERS.has(provider)) {
213
+ return provider === "chatgpt" ? "openai" : getManagedUpstream(model);
214
+ }
215
+ return resolveEntryProviderKind(provider, model) ?? provider;
216
+ }
217
+
196
218
  /**
197
219
  * Resolve a Provider through a named `provider_connection`.
198
220
  *
@@ -16,6 +16,15 @@ export function hasVisibleText(content: ReadonlyArray<ContentBlock>): boolean {
16
16
  );
17
17
  }
18
18
 
19
+ /** Whether `content` carries nothing a provider can serialize: no blocks, or only blank text. */
20
+ export function isEffectivelyEmptyContent(
21
+ content: ReadonlyArray<ContentBlock>,
22
+ ): boolean {
23
+ return content.every(
24
+ (block) => block.type === "text" && block.text.trim().length === 0,
25
+ );
26
+ }
27
+
19
28
  /** Whether `content` carries at least one tool call. */
20
29
  export function hasToolUse(content: ReadonlyArray<ContentBlock>): boolean {
21
30
  return content.some((block) => block.type === "tool_use");
@@ -45,6 +45,14 @@ export interface FetchProviderCatalogEntry {
45
45
  /** Privacy-policy URL surfaced in marketing data-sharing docs.
46
46
  * BYOK providers only. */
47
47
  readonly privacyPolicyUrl?: string;
48
+ /**
49
+ * When true, settings UIs show an optional API Base field. Empty /
50
+ * omitted base uses {@link defaultApiBase}.
51
+ */
52
+ readonly supportsApiBase?: boolean;
53
+ /** Cloud default origin when `supportsApiBase` is true and the user
54
+ * leaves API Base empty. */
55
+ readonly defaultApiBase?: string;
48
56
  }
49
57
 
50
58
  export const FETCH_PROVIDER_CATALOG: readonly FetchProviderCatalogEntry[] = [
@@ -65,6 +73,17 @@ export const FETCH_PROVIDER_CATALOG: readonly FetchProviderCatalogEntry[] = [
65
73
  secretKey: "firecrawl",
66
74
  privacyPolicyUrl: "https://www.firecrawl.dev/privacy-policy",
67
75
  },
76
+ {
77
+ id: "fastcrw",
78
+ displayName: "fastCRW",
79
+ kind: "byok",
80
+ apiKeyPrefix: "crw_live_...",
81
+ envVar: "FASTCRW_API_KEY",
82
+ secretKey: "fastcrw",
83
+ privacyPolicyUrl: "https://fastcrw.com/privacy",
84
+ supportsApiBase: true,
85
+ defaultApiBase: "https://api.fastcrw.com",
86
+ },
68
87
  ];
69
88
 
70
89
  /** Provider ids accepted by the web-fetch config schema. */
@@ -720,15 +720,23 @@ export class GeminiProvider implements Provider {
720
720
  model,
721
721
  role,
722
722
  );
723
- if (parts.length > 0) {
724
- result.push({ role, parts });
723
+ // Gemini keeps functionResponse parts separate from other parts. Tool
724
+ // result media follows the function response before any remaining text.
725
+ const functionResponseParts = parts.filter(
726
+ (part) => part.functionResponse !== undefined,
727
+ );
728
+ const otherParts = parts.filter(
729
+ (part) => part.functionResponse === undefined,
730
+ );
731
+ if (functionResponseParts.length > 0) {
732
+ result.push({ role, parts: functionResponseParts });
725
733
  }
726
- // Gemini requires that a Content with functionResponse parts must not
727
- // contain non-functionResponse parts. Emit tool-result images in a
728
- // separate user Content entry.
729
734
  if (toolResultMediaParts.length > 0) {
730
735
  result.push({ role: "user", parts: toolResultMediaParts });
731
736
  }
737
+ if (otherParts.length > 0) {
738
+ result.push({ role, parts: otherParts });
739
+ }
732
740
  }
733
741
 
734
742
  return result;
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { testInferenceConnection } from "../endpoint-probe.js";
4
+
5
+ // Keyless auth so the probe never touches the vault; the fetch stub records
6
+ // the request instead of dialing out.
7
+ const CONNECTION = {
8
+ provider: "openai-compatible",
9
+ auth: { type: "none" } as const,
10
+ baseUrl: "https://integrate.api.nvidia.com",
11
+ models: [{ id: "meta/llama-3.1-8b-instruct" }],
12
+ };
13
+
14
+ function stubFetch(status: number, calls: { url: string; body: unknown }[]) {
15
+ return (async (url: RequestInfo | URL, init?: RequestInit) => {
16
+ calls.push({
17
+ url: String(url),
18
+ body: JSON.parse(String(init?.body)),
19
+ });
20
+ return new Response(status < 400 ? "{}" : "404 page not found", { status });
21
+ }) as typeof fetch;
22
+ }
23
+
24
+ describe("testInferenceConnection", () => {
25
+ test("reports ok:false with a base-path hint on 404", async () => {
26
+ const calls: { url: string; body: unknown }[] = [];
27
+ const result = await testInferenceConnection(
28
+ CONNECTION,
29
+ stubFetch(404, calls),
30
+ );
31
+
32
+ expect(result).toMatchObject({
33
+ ok: false,
34
+ status: 404,
35
+ resolved_url: "https://integrate.api.nvidia.com/chat/completions",
36
+ error_class: "http_error",
37
+ });
38
+ expect(result?.hint).toContain("/v1");
39
+ expect(calls[0].body).toMatchObject({
40
+ model: "meta/llama-3.1-8b-instruct",
41
+ max_tokens: 1,
42
+ });
43
+ });
44
+
45
+ test("reports ok:true on 200 from a correct base URL", async () => {
46
+ const calls: { url: string; body: unknown }[] = [];
47
+ const result = await testInferenceConnection(
48
+ { ...CONNECTION, baseUrl: "https://integrate.api.nvidia.com/v1" },
49
+ stubFetch(200, calls),
50
+ );
51
+
52
+ expect(result).toMatchObject({
53
+ ok: true,
54
+ status: 200,
55
+ resolved_url: "https://integrate.api.nvidia.com/v1/chat/completions",
56
+ });
57
+ expect(result?.hint).toBeUndefined();
58
+ });
59
+
60
+ test("hints at the credential on 401", async () => {
61
+ const result = await testInferenceConnection(
62
+ CONNECTION,
63
+ stubFetch(401, []),
64
+ );
65
+ expect(result).toMatchObject({ ok: false, status: 401 });
66
+ expect(result?.hint).toContain("API key");
67
+ });
68
+
69
+ test("reports a network error_class when the endpoint is unreachable", async () => {
70
+ const failingFetch = (async () => {
71
+ throw new Error("connection refused");
72
+ }) as unknown as typeof fetch;
73
+ const result = await testInferenceConnection(CONNECTION, failingFetch);
74
+ expect(result).toMatchObject({ ok: false, error_class: "network" });
75
+ expect(result?.hint).toContain("connection refused");
76
+ });
77
+
78
+ test("skips when there is no base URL or no model to probe with", async () => {
79
+ const neverFetch = (async () => {
80
+ throw new Error("should not be called");
81
+ }) as unknown as typeof fetch;
82
+ expect(
83
+ await testInferenceConnection(
84
+ { ...CONNECTION, baseUrl: null },
85
+ neverFetch,
86
+ ),
87
+ ).toBeNull();
88
+ expect(
89
+ await testInferenceConnection({ ...CONNECTION, models: [] }, neverFetch),
90
+ ).toBeNull();
91
+ });
92
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { validateInferenceProfileConfig } from "../../../api/constants/profile-config-validation.js";
4
+
5
+ describe("validateInferenceProfileConfig", () => {
6
+ test("rejects an output budget that consumes the whole context window", () => {
7
+ const issue = validateInferenceProfileConfig({
8
+ maxTokens: 300000,
9
+ modelContextWindowTokens: 300000,
10
+ });
11
+ expect(issue?.field).toBe("maxTokens");
12
+ expect(issue?.message).toContain("context window");
13
+ });
14
+
15
+ test("passes an output budget that leaves input room", () => {
16
+ expect(
17
+ validateInferenceProfileConfig({
18
+ maxTokens: 8000,
19
+ modelContextWindowTokens: 300000,
20
+ }),
21
+ ).toBeNull();
22
+ });
23
+
24
+ test("rejects an output budget over the model's output cap", () => {
25
+ const issue = validateInferenceProfileConfig({
26
+ maxTokens: 100000,
27
+ modelMaxOutputTokens: 64000,
28
+ modelContextWindowTokens: 200000,
29
+ });
30
+ expect(issue?.message).toContain("maximum output");
31
+ });
32
+
33
+ test("silent when either side of the judgment is unknown", () => {
34
+ expect(validateInferenceProfileConfig({ maxTokens: 300000 })).toBeNull();
35
+ expect(
36
+ validateInferenceProfileConfig({ modelContextWindowTokens: 300000 }),
37
+ ).toBeNull();
38
+ });
39
+ });