@vellumai/assistant 0.11.10 → 0.11.11-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 (199) hide show
  1. package/AGENTS.md +1 -1
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  3. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  10. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  11. package/node_modules/@vellumai/service-contracts/src/__tests__/plan-credit.test.ts +51 -0
  12. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  13. package/node_modules/@vellumai/service-contracts/src/plan-credit.ts +66 -0
  14. package/openapi.yaml +152 -55
  15. package/package.json +1 -1
  16. package/src/__tests__/anthropic-provider.test.ts +60 -0
  17. package/src/__tests__/config-get-vision-flag.test.ts +36 -0
  18. package/src/__tests__/conversation-error.test.ts +38 -0
  19. package/src/__tests__/copy-composer-tc-templates.test.ts +2 -2
  20. package/src/__tests__/guardian-action-service-scope.test.ts +53 -0
  21. package/src/__tests__/headless-browser-mode.test.ts +63 -13
  22. package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
  23. package/src/__tests__/heartbeat-service.test.ts +118 -27
  24. package/src/__tests__/host-app-control-proxy.test.ts +2 -2
  25. package/src/__tests__/host-app-control-routes.test.ts +41 -0
  26. package/src/__tests__/host-bash-routes.test.ts +30 -1
  27. package/src/__tests__/host-browser-routes.test.ts +34 -0
  28. package/src/__tests__/host-cu-routes-targeted.test.ts +29 -0
  29. package/src/__tests__/host-file-routes-targeted.test.ts +29 -0
  30. package/src/__tests__/host-proxy-base.test.ts +1 -0
  31. package/src/__tests__/host-transfer-routes-targeted.test.ts +86 -0
  32. package/src/__tests__/llm-schema.test.ts +32 -0
  33. package/src/__tests__/mcp-config-secret-boundary.test.ts +2 -0
  34. package/src/__tests__/memory-config-file-parity.test.ts +18 -0
  35. package/src/__tests__/memory-retrieval-hook.test.ts +15 -0
  36. package/src/__tests__/messaging-send-tool.test.ts +159 -13
  37. package/src/__tests__/messaging-shared.test.ts +19 -0
  38. package/src/__tests__/notification-events-source-context-probe.test.ts +211 -0
  39. package/src/__tests__/openai-responses-prompt-cache.test.ts +40 -6
  40. package/src/__tests__/outlook-messaging-provider.test.ts +9 -0
  41. package/src/__tests__/provider-catalog-visibility.test.ts +2 -2
  42. package/src/__tests__/schedule-routes.test.ts +30 -1
  43. package/src/__tests__/scheduler-result-notification.test.ts +209 -0
  44. package/src/__tests__/skills-install-extract.test.ts +26 -0
  45. package/src/__tests__/skills-install-staging.test.ts +11 -2
  46. package/src/api/constants/personality-sliders.test.ts +21 -0
  47. package/src/api/constants/personality-sliders.ts +64 -0
  48. package/src/api/index.ts +8 -0
  49. package/src/archive/ustar-size.test.ts +32 -0
  50. package/src/archive/ustar-size.ts +23 -0
  51. package/src/background-wake/next-wake.test.ts +13 -0
  52. package/src/background-wake/next-wake.ts +4 -2
  53. package/src/calls/voice-session-bridge.ts +26 -0
  54. package/src/cli/__tests__/catalog-search-help.test.ts +1 -0
  55. package/src/cli/commands/__tests__/plugins-search.test.ts +81 -0
  56. package/src/cli/commands/config.help.ts +3 -1
  57. package/src/cli/commands/memory/worker.ts +1 -1
  58. package/src/cli/commands/platform/__tests__/credits-format.test.ts +94 -5
  59. package/src/cli/commands/platform/__tests__/credits.test.ts +10 -0
  60. package/src/cli/commands/platform/credits-format.ts +57 -0
  61. package/src/cli/commands/platform/index.help.ts +19 -3
  62. package/src/cli/commands/plugins.help.ts +4 -1
  63. package/src/cli/commands/plugins.ts +2 -1
  64. package/src/cli/lib/bundled-marketplace.json +1 -1
  65. package/src/cli/lib/install-from-platform.ts +8 -10
  66. package/src/config/bundled-skills/messaging/SKILL.md +2 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +4 -4
  68. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +85 -1
  69. package/src/config/bundled-skills/messaging/tools/shared.ts +6 -0
  70. package/src/config/bundled-skills/schedule/SKILL.md +9 -1
  71. package/src/config/bundled-skills/screen-annotation/SKILL.md +87 -20
  72. package/src/config/bundled-skills/screen-annotation/TOOLS.json +21 -5
  73. package/src/config/bundled-skills/screen-annotation/tools/screen-annotation.test.ts +67 -0
  74. package/src/config/feature-flag-registry.json +17 -1
  75. package/src/config/input-modalities.test.ts +71 -0
  76. package/src/config/input-modalities.ts +95 -0
  77. package/src/config/schemas/heartbeat.ts +3 -3
  78. package/src/config/schemas/llm.ts +13 -3
  79. package/src/config/schemas/memory-storage.test.ts +39 -0
  80. package/src/config/schemas/memory-storage.ts +21 -0
  81. package/src/context/token-estimator.ts +2 -2
  82. package/src/daemon/__tests__/embedding-reconcile.test.ts +77 -0
  83. package/src/daemon/__tests__/lifecycle-embedding-reconcile.test.ts +3 -0
  84. package/src/daemon/conversation-error.ts +20 -7
  85. package/src/daemon/embedding-reconcile.ts +64 -1
  86. package/src/daemon/handlers/config-embeddings.ts +31 -0
  87. package/src/daemon/host-app-control-proxy.ts +2 -0
  88. package/src/daemon/host-bash-proxy.ts +1 -0
  89. package/src/daemon/host-cu-proxy.ts +6 -6
  90. package/src/daemon/host-file-proxy.ts +6 -6
  91. package/src/daemon/host-proxy-base.ts +10 -9
  92. package/src/daemon/host-transfer-proxy.ts +21 -24
  93. package/src/daemon/mcp-reload-service.ts +6 -0
  94. package/src/daemon/providers-setup.ts +5 -26
  95. package/src/heartbeat/heartbeat-service.ts +72 -13
  96. package/src/live-voice/__tests__/live-voice-photo.test.ts +72 -0
  97. package/src/live-voice/__tests__/live-voice-sight-frame.test.ts +81 -0
  98. package/src/live-voice/live-voice-photo.ts +68 -4
  99. package/src/live-voice/live-voice-session.ts +36 -2
  100. package/src/live-voice/protocol.ts +98 -1
  101. package/src/mcp/__tests__/reload-signal-emission.test.ts +54 -0
  102. package/src/mcp/__tests__/reload-signal.test.ts +57 -0
  103. package/src/mcp/__tests__/startup.test.ts +150 -0
  104. package/src/mcp/reload-signal.ts +50 -0
  105. package/src/mcp/startup.ts +101 -0
  106. package/src/messaging/providers/outlook/adapter.ts +2 -13
  107. package/src/messaging/providers/outlook/client.ts +13 -1
  108. package/src/messaging/providers/outlook/types.ts +3 -0
  109. package/src/notifications/__tests__/decision-engine.test.ts +79 -6
  110. package/src/notifications/__tests__/home-feed-side-effect.test.ts +27 -0
  111. package/src/notifications/__tests__/schedule-result-producer.test.ts +397 -0
  112. package/src/notifications/copy-composer.ts +33 -11
  113. package/src/notifications/decision-engine.ts +48 -11
  114. package/src/notifications/events-store.ts +68 -2
  115. package/src/notifications/home-feed-side-effect.ts +15 -3
  116. package/src/notifications/schedule-result-producer.ts +275 -0
  117. package/src/notifications/signal.ts +5 -0
  118. package/src/oauth/seed-providers.ts +3 -2
  119. package/src/persistence/conversation-crud.ts +38 -0
  120. package/src/persistence/embeddings/embedding-backend.test.ts +162 -0
  121. package/src/persistence/embeddings/embedding-backend.ts +148 -11
  122. package/src/persistence/embeddings/embedding-openai.test.ts +113 -0
  123. package/src/persistence/embeddings/embedding-openai.ts +55 -4
  124. package/src/persistence/embeddings/embedding-types.test.ts +24 -0
  125. package/src/persistence/embeddings/embedding-types.ts +25 -1
  126. package/src/persistence/job-utils.ts +6 -2
  127. package/src/plugin-api/index.ts +2 -1
  128. package/src/plugin-api/vision-support.test.ts +81 -0
  129. package/src/plugin-api/vision-support.ts +57 -10
  130. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +8 -0
  131. package/src/plugins/defaults/memory/jobs/__tests__/embed-concept-page.test.ts +1 -0
  132. package/src/plugins/defaults/memory/jobs/embed-concept-page.ts +11 -3
  133. package/src/plugins/defaults/memory/src/__tests__/memory-worker-routes.test.ts +20 -0
  134. package/src/plugins/defaults/memory/src/memory-worker-routes.ts +3 -1
  135. package/src/plugins/defaults/memory/substrate/__tests__/reembed-job.test.ts +1 -0
  136. package/src/plugins/defaults/memory/v2/__tests__/backfill-jobs.test.ts +1 -0
  137. package/src/plugins/defaults/memory/v3/__tests__/section-dense-store.test.ts +82 -0
  138. package/src/plugins/defaults/memory/v3/section-dense-store.ts +9 -27
  139. package/src/providers/__tests__/provider-secret-catalog.test.ts +4 -0
  140. package/src/providers/__tests__/xml-tool-call-salvage.test.ts +172 -0
  141. package/src/providers/anthropic/client.ts +9 -4
  142. package/src/providers/inline-audio-support.test.ts +34 -0
  143. package/src/providers/inline-audio-support.ts +27 -0
  144. package/src/providers/model-catalog.ts +22 -2
  145. package/src/providers/openai/__tests__/api-error-normalization.test.ts +50 -0
  146. package/src/providers/openai/__tests__/chat-completions-extra-body.test.ts +51 -0
  147. package/src/providers/openai/__tests__/chat-completions-provider-xml-salvage.test.ts +291 -0
  148. package/src/providers/openai/api-error-normalization.ts +18 -3
  149. package/src/providers/openai/chat-completions-provider.ts +113 -13
  150. package/src/providers/openai/responses-provider.ts +10 -8
  151. package/src/providers/provider-secret-catalog.ts +11 -1
  152. package/src/providers/vellum/client.ts +14 -1
  153. package/src/providers/vellum/personality-directions.test.ts +163 -0
  154. package/src/providers/vellum/personality-directions.ts +120 -0
  155. package/src/providers/xml-tool-call-salvage.ts +212 -0
  156. package/src/runtime/__tests__/background-job-runner.test.ts +42 -4
  157. package/src/runtime/auth/__tests__/guard-tests.test.ts +1 -6
  158. package/src/runtime/auth/__tests__/route-policy.test.ts +65 -0
  159. package/src/runtime/auth/same-actor.ts +29 -5
  160. package/src/runtime/background-job-runner.ts +38 -29
  161. package/src/runtime/guardian-action-service.ts +20 -1
  162. package/src/runtime/migrations/vbundle-validator.ts +3 -3
  163. package/src/runtime/pending-interactions.ts +4 -5
  164. package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
  165. package/src/runtime/routes/__tests__/platform-credits-routes.test.ts +99 -5
  166. package/src/runtime/routes/conversation-query-routes.ts +53 -11
  167. package/src/runtime/routes/document-comments-routes.ts +8 -2
  168. package/src/runtime/routes/guardian-action-routes.ts +7 -0
  169. package/src/runtime/routes/heartbeat-routes.ts +41 -53
  170. package/src/runtime/routes/host-app-control-routes.ts +10 -36
  171. package/src/runtime/routes/host-bash-routes.ts +11 -45
  172. package/src/runtime/routes/host-browser-routes.ts +24 -0
  173. package/src/runtime/routes/host-cu-routes.ts +11 -45
  174. package/src/runtime/routes/host-file-routes.ts +11 -44
  175. package/src/runtime/routes/host-proxy-result-binding.ts +57 -0
  176. package/src/runtime/routes/host-transfer-routes.ts +33 -25
  177. package/src/runtime/routes/index.ts +0 -2
  178. package/src/runtime/routes/integrations/a2a.ts +12 -3
  179. package/src/runtime/routes/integrations/vercel.ts +13 -3
  180. package/src/runtime/routes/platform-routes.ts +121 -24
  181. package/src/runtime/routes/schedule-routes.ts +15 -1
  182. package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
  183. package/src/schedule/__tests__/worker-mcp-tools.test.ts +88 -0
  184. package/src/schedule/schedule-timezone.ts +25 -0
  185. package/src/schedule/scheduler.ts +19 -0
  186. package/src/schedule/worker.ts +119 -2
  187. package/src/skills/catalog-install.ts +40 -5
  188. package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
  189. package/src/tools/browser/browser-execution.ts +117 -53
  190. package/src/tools/browser/browser-status-constants.ts +6 -6
  191. package/src/tools/capability-offer.test.ts +87 -0
  192. package/src/tools/capability-offer.ts +93 -0
  193. package/src/tools/terminal/__tests__/safe-env.test.ts +17 -0
  194. package/src/tools/terminal/safe-env.ts +3 -2
  195. package/src/util/provider-error-patterns.ts +34 -0
  196. package/src/watcher/__tests__/engine.test.ts +5 -2
  197. package/src/watcher/engine.ts +7 -4
  198. package/src/runtime/routes/__tests__/sight-frame-routes.test.ts +0 -487
  199. package/src/runtime/routes/sight-frame-routes.ts +0 -136
@@ -70,12 +70,36 @@ export function embeddingInputContentHash(input: EmbeddingInput): string {
70
70
  return hash.digest("hex");
71
71
  }
72
72
 
73
+ /**
74
+ * Durable cache identity for an embedding input. Folds provider extras
75
+ * (Gemini task type / dimensions, custom endpoint URL / dimensions) into the
76
+ * bare content hash so a change that alters the vector for identical text is
77
+ * a cache miss. With no extras this is the bare {@link embeddingInputContentHash}.
78
+ */
79
+ export function embeddingContentHashWithExtras(
80
+ input: EmbeddingInput,
81
+ extras: string[] = [],
82
+ ): string {
83
+ const base = embeddingInputContentHash(input);
84
+ if (extras.length === 0) {
85
+ return base;
86
+ }
87
+ return createHash("sha256")
88
+ .update(`${base}\0${extras.join("\0")}`)
89
+ .digest("hex");
90
+ }
91
+
73
92
  // ---------------------------------------------------------------------------
74
93
  // Backend interface types (extracted from embedding-backend.ts to break
75
94
  // circular imports between the factory and provider implementations)
76
95
  // ---------------------------------------------------------------------------
77
96
 
78
- export type EmbeddingProviderName = "local" | "openai" | "gemini" | "ollama";
97
+ export type EmbeddingProviderName =
98
+ | "local"
99
+ | "openai"
100
+ | "gemini"
101
+ | "ollama"
102
+ | "custom";
79
103
 
80
104
  export interface EmbeddingRequestOptions {
81
105
  signal?: AbortSignal;
@@ -7,13 +7,14 @@ import { BackendUnavailableError } from "../util/errors.js";
7
7
  import { getLogger } from "../util/logger.js";
8
8
  import { getMemoryDb } from "./db-connection.js";
9
9
  import {
10
+ durableEmbeddingCacheExtras,
10
11
  embedWithBackend,
11
12
  generateSparseEmbedding,
12
13
  getMemoryBackendStatus,
13
14
  } from "./embeddings/embedding-backend.js";
14
15
  import type { EmbeddingInput } from "./embeddings/embedding-types.js";
15
16
  import {
16
- embeddingInputContentHash,
17
+ embeddingContentHashWithExtras,
17
18
  normalizeEmbeddingInput,
18
19
  } from "./embeddings/embedding-types.js";
19
20
  import { withQdrantBreaker } from "./embeddings/qdrant-circuit-breaker.js";
@@ -186,7 +187,10 @@ export async function embedAndUpsert(
186
187
  );
187
188
  }
188
189
 
189
- const contentHash = embeddingInputContentHash(input);
190
+ const contentHash = embeddingContentHashWithExtras(
191
+ input,
192
+ durableEmbeddingCacheExtras(config, status.provider),
193
+ );
190
194
  let provider = status.provider;
191
195
  let model = status.model!;
192
196
  let vector: number[];
@@ -164,7 +164,8 @@ export { publishEvent } from "./publish-event.js";
164
164
  // model id, a profile key, or a `ModelProfileInfo`; a bare string is resolved
165
165
  // as a model id first and then as a profile key. Profile resolution merges over
166
166
  // the workspace default and infers the provider for model-only profiles, then
167
- // looks up the model catalog's `supportsVision` flag (mix profiles are
167
+ // looks up the model catalog's `supportsVision` flag, with a profile
168
+ // `inputModalities.image` override winning when set (mix profiles are
168
169
  // vision-capable if any arm is). Returns false when nothing resolves.
169
170
  export { doesSupportVision } from "./vision-support.js";
170
171
  // Resolve a stored credential to its plaintext value — the same value
@@ -17,6 +17,12 @@ interface MockProfileEntry {
17
17
  model?: string;
18
18
  status?: string;
19
19
  mix?: Array<{ profile: string; weight: number }>;
20
+ inputModalities?: {
21
+ text?: { enabled?: boolean; supported?: boolean };
22
+ image?: { enabled?: boolean; supported?: boolean };
23
+ audio?: { enabled?: boolean; supported?: boolean };
24
+ video?: { enabled?: boolean; supported?: boolean };
25
+ };
20
26
  }
21
27
 
22
28
  let mockProfiles: Record<string, MockProfileEntry> = {};
@@ -103,6 +109,49 @@ describe("doesSupportVision", () => {
103
109
  expect(doesSupportVision(profile("unknown-model"))).toBe(false);
104
110
  });
105
111
 
112
+ test("an openai-compatible profile sends images when the override declares support", () => {
113
+ setMockConfig({
114
+ gateway: {
115
+ provider: "openai-compatible",
116
+ model: "qwen2.5-vl",
117
+ inputModalities: { image: { enabled: true, supported: true } },
118
+ },
119
+ });
120
+ expect(doesSupportVision(profile("gateway"))).toBe(true);
121
+ });
122
+
123
+ test("an openai-compatible profile without an override still fail-closes", () => {
124
+ setMockConfig({
125
+ gateway: {
126
+ provider: "openai-compatible",
127
+ model: "qwen2.5-vl",
128
+ },
129
+ });
130
+ expect(doesSupportVision(profile("gateway"))).toBe(false);
131
+ });
132
+
133
+ test("enabling image without declaring support keeps the catalog value", () => {
134
+ setMockConfig({
135
+ gateway: {
136
+ provider: "openai-compatible",
137
+ model: "qwen2.5-vl",
138
+ inputModalities: { image: { enabled: true } },
139
+ },
140
+ });
141
+ expect(doesSupportVision(profile("gateway"))).toBe(false);
142
+ });
143
+
144
+ test("a catalog vision model can be policy-disabled on the profile", () => {
145
+ setMockConfig({
146
+ "vision-profile": {
147
+ provider: "anthropic",
148
+ model: "claude-opus-4-6",
149
+ inputModalities: { image: { enabled: false } },
150
+ },
151
+ });
152
+ expect(doesSupportVision(profile("vision-profile"))).toBe(false);
153
+ });
154
+
106
155
  test("implies the provider from the catalog when profile only sets model", () => {
107
156
  setMockConfig({ "model-only": { model: "claude-opus-4-6" } });
108
157
  expect(doesSupportVision(profile("model-only"))).toBe(true);
@@ -144,6 +193,27 @@ describe("doesSupportVision", () => {
144
193
  expect(doesSupportVision(profile("mix-profile"))).toBe(true);
145
194
  });
146
195
 
196
+ test("mix profile returns true when an unknown arm declares image support", () => {
197
+ setMockConfig({
198
+ "mix-profile": {
199
+ mix: [
200
+ { profile: "text-arm", weight: 0.5 },
201
+ { profile: "gateway-arm", weight: 0.5 },
202
+ ],
203
+ },
204
+ "text-arm": {
205
+ provider: "fireworks",
206
+ model: "accounts/fireworks/models/glm-5p2",
207
+ },
208
+ "gateway-arm": {
209
+ provider: "openai-compatible",
210
+ model: "qwen2.5-vl",
211
+ inputModalities: { image: { enabled: true, supported: true } },
212
+ },
213
+ });
214
+ expect(doesSupportVision(profile("mix-profile"))).toBe(true);
215
+ });
216
+
147
217
  test("mix profile returns false when all arms are text-only", () => {
148
218
  setMockConfig({
149
219
  "mix-profile": {
@@ -315,4 +385,15 @@ describe("doesSupportVision with a bare string", () => {
315
385
  setMockConfig({});
316
386
  expect(doesSupportVision("some-unknown-string")).toBe(false);
317
387
  });
388
+
389
+ test("honors a free-text image override when looked up by model id", () => {
390
+ setMockConfig({
391
+ gateway: {
392
+ provider: "openai-compatible",
393
+ model: "qwen2.5-vl",
394
+ inputModalities: { image: { enabled: true, supported: true } },
395
+ },
396
+ });
397
+ expect(doesSupportVision("qwen2.5-vl")).toBe(true);
398
+ });
318
399
  });
@@ -22,6 +22,12 @@
22
22
  */
23
23
 
24
24
  import { resolveDefaultProfileForProvider } from "../config/default-profile-catalog.js";
25
+ import {
26
+ catalogEntryFor,
27
+ type InputModalities,
28
+ modalitiesOf,
29
+ resolveModalityOverride,
30
+ } from "../config/input-modalities.js";
25
31
  import { getConfig } from "../config/loader.js";
26
32
  import { resolveEntryProviderKind } from "../providers/connection-resolution.js";
27
33
  import { ROUTING_IDENTITY_PROVIDERS } from "../providers/inference/auth.js";
@@ -42,14 +48,52 @@ export function doesSupportVision(
42
48
  modelOrProfile: ModelProfileInfo | string,
43
49
  ): boolean {
44
50
  if (typeof modelOrProfile === "string") {
45
- // Concrete model id first, then fall back to treating it as a profile key.
51
+ // Concrete catalog model id first, then a profile key, then any profile
52
+ // bound to that model id (so a free-text override is visible to callers
53
+ // that only have the provider-reported model, e.g. post-tool-use).
46
54
  return (
47
- modelVision(modelOrProfile) ?? profileVision(modelOrProfile) ?? false
55
+ modelVision(modelOrProfile) ??
56
+ profileVision(modelOrProfile) ??
57
+ modelVisionFromBoundProfiles(modelOrProfile) ??
58
+ false
48
59
  );
49
60
  }
50
61
  return profileVision(modelOrProfile.key) ?? false;
51
62
  }
52
63
 
64
+ /**
65
+ * Vision from a profile whose `model` field matches `model`. Used when the
66
+ * catalog does not know the id (openai-compatible / free-text). Any bound
67
+ * profile that declares image support wins so tool-result images are not
68
+ * captioned for a model the user marked as vision-capable.
69
+ */
70
+ function modelVisionFromBoundProfiles(model: string): boolean | undefined {
71
+ const profiles = getConfig().llm.profiles ?? {};
72
+ let sawUnknown = false;
73
+ let sawFalse = false;
74
+ for (const entry of Object.values(profiles)) {
75
+ if (entry?.model !== model || entry.mix != null) {
76
+ continue;
77
+ }
78
+ const vision = resolveEntryVision(entry);
79
+ if (vision === true) {
80
+ return true;
81
+ }
82
+ if (vision === false) {
83
+ sawFalse = true;
84
+ } else {
85
+ sawUnknown = true;
86
+ }
87
+ }
88
+ if (sawUnknown) {
89
+ return undefined;
90
+ }
91
+ if (sawFalse) {
92
+ return false;
93
+ }
94
+ return undefined;
95
+ }
96
+
53
97
  /**
54
98
  * Catalog vision flag for a concrete model id, or `undefined` when the catalog
55
99
  * doesn't know the model. The same model id carries the same capability under
@@ -110,13 +154,16 @@ function profileVision(profileKey: string): boolean | undefined {
110
154
  /**
111
155
  * Resolve whether a concrete (non-mix) profile entry supports vision from its
112
156
  * own `(provider, model)`, inferring the provider from the catalog when only
113
- * the model is set. Returns `undefined` when the effective `(provider, model)`
114
- * can't be determined or isn't in the catalog — an entry that omits its model
115
- * is not a usable resolution target, so it fails safe to "caption".
157
+ * the model is set. A profile `inputModalities.image` override wins over the
158
+ * catalog. Returns `undefined` when the effective `(provider, model)` can't
159
+ * be determined or isn't in the catalog and no override is set — an entry
160
+ * that omits its model is not a usable resolution target, so it fails safe
161
+ * to "caption".
116
162
  */
117
163
  function resolveEntryVision(entry: {
118
164
  provider?: string;
119
165
  model?: string;
166
+ inputModalities?: InputModalities | null;
120
167
  }): boolean | undefined {
121
168
  // Routing identities ("vellum"/"chatgpt") are not catalog providers; the
122
169
  // model's catalog owner is the capability source for them.
@@ -142,12 +189,12 @@ function resolveEntryVision(entry: {
142
189
  (typeof model === "string" ? getCatalogProviderForModel(model) : undefined);
143
190
 
144
191
  if (typeof effectiveProvider !== "string" || typeof model !== "string") {
145
- return undefined;
192
+ return resolveModalityOverride(modalitiesOf(entry)?.image, undefined);
146
193
  }
147
194
 
148
- const catalogProvider = PROVIDER_CATALOG.find(
149
- (p) => p.id === effectiveProvider,
195
+ const catalogModel = catalogEntryFor(effectiveProvider, model);
196
+ return resolveModalityOverride(
197
+ modalitiesOf(entry)?.image,
198
+ catalogModel?.supportsVision,
150
199
  );
151
- const catalogModel = catalogProvider?.models.find((m) => m.id === model);
152
- return catalogModel?.supportsVision;
153
200
  }
@@ -25,6 +25,10 @@
25
25
  * Registered first in the `user-prompt-submit` chain, so it heads the chain
26
26
  * ahead of history repair and title generation: those later hooks see the
27
27
  * fully assembled, memory-injected history.
28
+ *
29
+ * Memory-retrospective wakes skip this hook. Those forks already carry copied
30
+ * injection metadata on historical messages. The remember-pass does not need a
31
+ * fresh selector or router call, and that call is what this hook awaits.
28
32
  */
29
33
 
30
34
  import type {
@@ -252,6 +256,10 @@ const userPromptSubmitMemoryRetrieval: HookFunction<
252
256
  // threaded in, mirroring how `applyRuntimeInjections` self-resolves its
253
257
  // per-turn inputs.
254
258
  const conversation = findConversationOrSubagent(ctx.conversationId);
259
+ if (conversation?.currentCallSite === "memoryRetrospective") {
260
+ return;
261
+ }
262
+
255
263
  const config = getConfig();
256
264
  const abortSignal = conversation?.abortController?.signal;
257
265
  const isTrustedActor =
@@ -67,6 +67,7 @@ mock.module(
67
67
  indices: [text.length % 100],
68
68
  values: [1],
69
69
  }),
70
+ durableEmbeddingCacheExtras: () => [],
70
71
  // Other exports from the real module — stubbed so adjacent imports
71
72
  // (e.g. via transitive `db.ts` → `indexer.ts`) don't crash on missing
72
73
  // names when the mock replaces the module wholesale.
@@ -23,10 +23,11 @@ import { and, eq } from "drizzle-orm";
23
23
  import type { AssistantConfig } from "../../../../config/types.js";
24
24
  import { getDb } from "../../../../persistence/db-connection.js";
25
25
  import {
26
+ durableEmbeddingCacheExtras,
26
27
  generateSparseEmbedding,
27
28
  getMemoryBackendStatus,
28
29
  } from "../../../../persistence/embeddings/embedding-backend.js";
29
- import { embeddingInputContentHash } from "../../../../persistence/embeddings/embedding-types.js";
30
+ import { embeddingContentHashWithExtras } from "../../../../persistence/embeddings/embedding-types.js";
30
31
  import { withQdrantBreaker } from "../../../../persistence/embeddings/qdrant-circuit-breaker.js";
31
32
  import {
32
33
  asString,
@@ -131,7 +132,11 @@ export async function embedConceptPageJob(
131
132
  // and (optional) summary share the same provider/model — but each gets
132
133
  // its own cache row keyed by a distinct targetId so summary edits don't
133
134
  // invalidate the body cache and vice versa.
134
- const bodyContentHash = embeddingInputContentHash({ type: "text", text });
135
+ const extras = durableEmbeddingCacheExtras(config, cacheProvider);
136
+ const bodyContentHash = embeddingContentHashWithExtras(
137
+ { type: "text", text },
138
+ extras,
139
+ );
135
140
  const bodyCache = mem
136
141
  ? readEmbeddingCache(mem, slug, cacheProvider, cacheModel, expectedDim)
137
142
  : null;
@@ -145,7 +150,10 @@ export async function embedConceptPageJob(
145
150
  const hasSummary = summaryText.length > 0;
146
151
  const summaryCacheId = `${slug}#summary`;
147
152
  const summaryContentHash = hasSummary
148
- ? embeddingInputContentHash({ type: "text", text: summaryText })
153
+ ? embeddingContentHashWithExtras(
154
+ { type: "text", text: summaryText },
155
+ extras,
156
+ )
149
157
  : undefined;
150
158
  const summaryCache =
151
159
  hasSummary && mem
@@ -176,6 +176,26 @@ describe("memory_worker_status", () => {
176
176
  ).toBe(true);
177
177
  });
178
178
 
179
+ test("accepts custom as a documented embedding provider on the wire", async () => {
180
+ backendStatus = {
181
+ enabled: true,
182
+ degraded: false,
183
+ provider: "custom",
184
+ model: "text-embedding-3-small",
185
+ reason: null,
186
+ };
187
+
188
+ const res = await handler("memory_worker_status")();
189
+
190
+ expect(res).toMatchObject({
191
+ embedding: { provider: "custom" },
192
+ });
193
+ expect(
194
+ embeddingStatusSchema.safeParse((res as { embedding: unknown }).embedding)
195
+ .success,
196
+ ).toBe(true);
197
+ });
198
+
179
199
  test("surfaces a degraded embedding backend with a reason when none is configured", async () => {
180
200
  backendStatus = {
181
201
  enabled: true,
@@ -32,7 +32,9 @@ const log = getLogger("memory-worker-routes");
32
32
  export const embeddingStatusSchema = z.object({
33
33
  enabled: z.boolean(),
34
34
  degraded: z.boolean(),
35
- provider: z.enum(["local", "openai", "gemini", "ollama"]).nullable(),
35
+ provider: z
36
+ .enum(["local", "openai", "gemini", "ollama", "custom"])
37
+ .nullable(),
36
38
  model: z.string().nullable(),
37
39
  reason: z.string().nullable(),
38
40
  });
@@ -54,6 +54,7 @@ mock.module(
54
54
  reason: null,
55
55
  }),
56
56
  selectedBackendSupportsMultimodal: async () => false,
57
+ durableEmbeddingCacheExtras: () => [],
57
58
  }),
58
59
  );
59
60
 
@@ -131,6 +131,7 @@ mock.module(
131
131
  reason: null,
132
132
  }),
133
133
  selectedBackendSupportsMultimodal: async () => false,
134
+ durableEmbeddingCacheExtras: () => [],
134
135
  }),
135
136
  );
136
137
 
@@ -33,6 +33,8 @@ const embedState = {
33
33
  // the section cache treats it as a miss.
34
34
  geminiTaskType: undefined as string | undefined,
35
35
  geminiDimensions: undefined as number | undefined,
36
+ customBaseUrl: undefined as string | undefined,
37
+ customDimensions: undefined as number | undefined,
36
38
  };
37
39
  mock.module(
38
40
  "../../../../../persistence/embeddings/embedding-backend.js",
@@ -64,6 +66,42 @@ mock.module(
64
66
  }
65
67
  return extras;
66
68
  },
69
+ customCacheExtras: () => {
70
+ const extras: string[] = [];
71
+ if (embedState.customBaseUrl) {
72
+ extras.push(`url=${embedState.customBaseUrl}`);
73
+ }
74
+ if (embedState.customDimensions != null) {
75
+ extras.push(`dim=${embedState.customDimensions}`);
76
+ }
77
+ return extras;
78
+ },
79
+ durableEmbeddingCacheExtras: (
80
+ _config: unknown,
81
+ provider: string | null,
82
+ ) => {
83
+ if (provider === "gemini") {
84
+ const extras: string[] = [];
85
+ if (embedState.geminiTaskType) {
86
+ extras.push(`task=${embedState.geminiTaskType}`);
87
+ }
88
+ if (embedState.geminiDimensions != null) {
89
+ extras.push(`dim=${embedState.geminiDimensions}`);
90
+ }
91
+ return extras;
92
+ }
93
+ if (provider === "custom") {
94
+ const extras: string[] = [];
95
+ if (embedState.customBaseUrl) {
96
+ extras.push(`url=${embedState.customBaseUrl}`);
97
+ }
98
+ if (embedState.customDimensions != null) {
99
+ extras.push(`dim=${embedState.customDimensions}`);
100
+ }
101
+ return extras;
102
+ }
103
+ return [];
104
+ },
67
105
  }),
68
106
  );
69
107
 
@@ -320,6 +358,8 @@ function resetState(): void {
320
358
  embedState.embedModel = "test-model";
321
359
  embedState.geminiTaskType = undefined;
322
360
  embedState.geminiDimensions = undefined;
361
+ embedState.customBaseUrl = undefined;
362
+ embedState.customDimensions = undefined;
323
363
  cacheState.store.clear();
324
364
  cacheState.reads.length = 0;
325
365
  checkpointState.deletes.length = 0;
@@ -767,6 +807,48 @@ describe("memory v3 section-dense-store — embedding cache", () => {
767
807
  expect(embedState.calls).toHaveLength(1);
768
808
  expect(state.upsertCalls).toHaveLength(2);
769
809
  });
810
+
811
+ test("a custom endpoint change re-embeds unchanged text (cache miss)", async () => {
812
+ state.collectionExists = true;
813
+ embedState.statusProvider = "custom";
814
+ embedState.statusModel = "text-embedding-3-small";
815
+ embedState.embedProvider = "custom";
816
+ embedState.embedModel = "text-embedding-3-small";
817
+ embedState.customBaseUrl = "http://127.0.0.1:4000/v1";
818
+
819
+ await upsertSections(CONFIG, [
820
+ section("people/alice", 0, "alice lead text"),
821
+ ]);
822
+ expect(embedState.calls).toHaveLength(1);
823
+
824
+ embedState.customBaseUrl = "http://127.0.0.1:4001/v1";
825
+
826
+ await upsertSections(CONFIG, [
827
+ section("people/alice", 0, "alice lead text"),
828
+ ]);
829
+
830
+ expect(embedState.calls).toEqual([
831
+ ["alice lead text"],
832
+ ["alice lead text"],
833
+ ]);
834
+ });
835
+
836
+ test("an unchanged custom endpoint still serves from cache", async () => {
837
+ state.collectionExists = true;
838
+ embedState.statusProvider = "custom";
839
+ embedState.statusModel = "text-embedding-3-small";
840
+ embedState.embedProvider = "custom";
841
+ embedState.embedModel = "text-embedding-3-small";
842
+ embedState.customBaseUrl = "http://127.0.0.1:4000/v1";
843
+
844
+ const sections = [section("people/alice", 0, "alice lead text")];
845
+
846
+ await upsertSections(CONFIG, sections);
847
+ await upsertSections(CONFIG, sections);
848
+
849
+ expect(embedState.calls).toHaveLength(1);
850
+ expect(state.upsertCalls).toHaveLength(2);
851
+ });
770
852
  });
771
853
 
772
854
  describe("memory v3 section-dense-store — delete", () => {
@@ -19,8 +19,6 @@
19
19
  // shared Qdrant client; the read side (`dense.ts`) reuses that client to query
20
20
  // this collection.
21
21
 
22
- import { createHash } from "node:crypto";
23
-
24
22
  import { QdrantClient as QdrantRestClient } from "@qdrant/js-client-rest";
25
23
  import { v5 as uuidv5 } from "uuid";
26
24
 
@@ -31,14 +29,14 @@ import {
31
29
  setMemoryCheckpoint,
32
30
  } from "../../../../persistence/checkpoints.js";
33
31
  import {
34
- geminiCacheExtras,
32
+ durableEmbeddingCacheExtras,
35
33
  getMemoryBackendStatus,
36
34
  } from "../../../../persistence/embeddings/embedding-backend.js";
37
35
  import {
38
36
  readEmbeddingCache,
39
37
  writeEmbeddingCache,
40
38
  } from "../../../../persistence/embeddings/embedding-cache.js";
41
- import { embeddingInputContentHash } from "../../../../persistence/embeddings/embedding-types.js";
39
+ import { embeddingContentHashWithExtras } from "../../../../persistence/embeddings/embedding-types.js";
42
40
  import { embedWithBackend, resolveQdrantUrl } from "../embeddings.js";
43
41
  import { getLogger } from "../logging.js";
44
42
  import { memoryDbOrNull } from "../memory-db.js";
@@ -514,24 +512,6 @@ function sectionCacheId(article: string, ordinal: number): string {
514
512
  return `${article}#${ordinal}`;
515
513
  }
516
514
 
517
- /**
518
- * Content hash a section's cached vector is keyed by. Folds the provider's
519
- * embedding-option extras (Gemini task type / output dimensions) into the base
520
- * text hash, so changing an option that alters the vector for identical text is
521
- * a cache miss that re-embeds. With no extras the bare text hash is returned
522
- * unchanged, keeping existing rows valid for non-Gemini and default-Gemini
523
- * configs.
524
- */
525
- function sectionContentHash(text: string, extras: string[]): string {
526
- const base = embeddingInputContentHash({ type: "text", text });
527
- if (extras.length === 0) {
528
- return base;
529
- }
530
- return createHash("sha256")
531
- .update(`${base}\0${extras.join("\0")}`)
532
- .digest("hex");
533
- }
534
-
535
515
  /**
536
516
  * Embed each section's `text` and upsert one point per section, keyed by a
537
517
  * deterministic `(article, ordinal)`-derived ID. Stable IDs mean re-upserting
@@ -641,11 +621,13 @@ async function embedSectionsCached(
641
621
  const status = await getMemoryBackendStatus(config);
642
622
  const mem = memoryDbOrNull("embedSectionsCached");
643
623
 
644
- // Only Gemini's options change the vector for identical text, so fold the
645
- // extras into the cache identity only when Gemini is the resolved provider;
646
- // other backends keep the bare text hash. See {@link sectionContentHash}.
647
- const extras = status.provider === "gemini" ? geminiCacheExtras(config) : [];
648
- const hashes = sections.map((s) => sectionContentHash(s.text, extras));
624
+ // Fold provider extras that change the vector for identical text (Gemini
625
+ // task type / dimensions, custom endpoint URL / dimensions) into the
626
+ // durable cache identity. Other backends keep the bare text hash.
627
+ const extras = durableEmbeddingCacheExtras(config, status.provider);
628
+ const hashes = sections.map((s) =>
629
+ embeddingContentHashWithExtras({ type: "text", text: s.text }, extras),
630
+ );
649
631
 
650
632
  const result: Array<number[] | undefined> = new Array(sections.length);
651
633
  const missIndices: number[] = [];
@@ -47,4 +47,8 @@ describe("API_KEY_PROVIDERS", () => {
47
47
  expect(API_KEY_PROVIDERS).toContain("tavily");
48
48
  expect(API_KEY_PROVIDERS).toContain("firecrawl");
49
49
  });
50
+
51
+ test("includes custom for OpenAI-compatible memory embeddings", () => {
52
+ expect(API_KEY_PROVIDERS).toContain("custom");
53
+ });
50
54
  });