@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
@@ -103,7 +103,7 @@ export interface SameActorLiveArgs {
103
103
  export interface SameActorPersistedArgs {
104
104
  sourceActorPrincipalId: string | undefined;
105
105
  targetActorPrincipalId: string | undefined;
106
- targetClientId: string;
106
+ targetClientId?: string;
107
107
  op: SameActorOp;
108
108
  /**
109
109
  * Fill-if-missing fallback for dev-bypass deployments: when the PERSISTED
@@ -136,12 +136,15 @@ function isLive(
136
136
  function detectRejection(
137
137
  args: SameActorLiveArgs | SameActorPersistedArgs,
138
138
  ): RejectionReason | undefined {
139
- const { sourceActorPrincipalId, targetClientId, op } = args;
139
+ const { sourceActorPrincipalId, op } = args;
140
+ const targetClientId = args.targetClientId;
140
141
  const targetActorPrincipalId = isLive(args)
141
- ? args.hub.getActorPrincipalIdForClient(targetClientId)
142
+ ? args.hub.getActorPrincipalIdForClient(args.targetClientId)
142
143
  : (args.targetActorPrincipalId ??
143
- (isHttpAuthDisabled()
144
- ? args.hubForMissingTarget?.getActorPrincipalIdForClient(targetClientId)
144
+ (isHttpAuthDisabled() && targetClientId
145
+ ? args.hubForMissingTarget?.getActorPrincipalIdForClient(
146
+ targetClientId,
147
+ )
145
148
  : undefined));
146
149
 
147
150
  let reason: RejectionReason | undefined;
@@ -261,6 +264,27 @@ export function pickSameUserAutoResolve(args: {
261
264
  return { kind: "ambiguous" };
262
265
  }
263
266
 
267
+ /**
268
+ * Actor principal persisted on a host-proxy pending interaction.
269
+ *
270
+ * Targeted dispatch snapshots the target client's actor. Untargeted dispatch
271
+ * snapshots the turn's source actor so result routes can still reject a
272
+ * different principal even when no `targetClientId` was bound.
273
+ */
274
+ export function snapshotHostProxyActorPrincipalId(args: {
275
+ hub: Pick<AssistantEventHub, "getActorPrincipalIdForClient">;
276
+ targetClientId?: string;
277
+ sourceActorPrincipalId?: string;
278
+ }): string | undefined {
279
+ if (args.targetClientId != null) {
280
+ return (
281
+ args.hub.getActorPrincipalIdForClient(args.targetClientId) ??
282
+ args.sourceActorPrincipalId
283
+ );
284
+ }
285
+ return args.sourceActorPrincipalId;
286
+ }
287
+
264
288
  /**
265
289
  * Standard error result for proxies when {@link pickSameUserAutoResolve}
266
290
  * returns `ambiguous`. Asks the caller to specify `target_client_id`.
@@ -171,17 +171,23 @@ export interface RunBackgroundJobOptions {
171
171
  */
172
172
  allowPreFirstUserMessage?: boolean;
173
173
  /**
174
- * Optional prompt-injection mitigation. When set, the runner adds three
175
- * messages to the conversation BEFORE invoking `processMessage`:
174
+ * Optional prompt-injection mitigation. When set, the runner seeds the
175
+ * conversation with a user/assistant/user sandwich BEFORE invoking
176
+ * `processMessage`:
176
177
  *
177
- * 1. `user` role: `preamble` — static, trusted instructions.
178
- * 2. `assistant` role: `content` — attacker-controllable payload (the LLM
178
+ * 1. `user` role: `preamble` - static, trusted instructions.
179
+ * 2. `assistant` role: `content` - attacker-controllable payload (the LLM
179
180
  * treats it as its own past output, not as user instructions).
180
- * 3. `user` role: `postamble` — static, trusted action prompt.
181
+ * 3. `user` role: `postamble` - static, trusted action prompt.
181
182
  *
182
- * `processMessage` is then invoked with whatever `prompt` the caller set
183
- * (often empty or a short kicker) since the conversation already carries
184
- * the seed.
183
+ * An empty `prompt` is a signal that the sandwich already carries the
184
+ * seed. In that case the runner persists only the preamble and untrusted
185
+ * content, then invokes `processMessage` with the postamble.
186
+ * `persistUserMessage` requires content or attachments, so the kickoff
187
+ * cannot be empty.
188
+ *
189
+ * A non-empty `prompt` still seeds all three sandwich messages, then
190
+ * invokes `processMessage` with that kicker.
185
191
  *
186
192
  * Used by the watcher engine to ingest external provider events safely:
187
193
  * a malicious Linear title or Gmail subject reaches the model only in
@@ -324,28 +330,29 @@ export async function runBackgroundJob(
324
330
  // messages. The LLM treats assistant-role content as its own prior
325
331
  // output, not as user instructions, so a malicious payload (e.g. a
326
332
  // crafted Linear title) cannot override the postamble's action prompt.
327
- if (opts.assistantSandwich) {
328
- await addMessage(
329
- conversation.id,
330
- "user",
331
- opts.assistantSandwich.preamble,
332
- { skipIndexing: true },
333
- );
334
- await addMessage(
335
- conversation.id,
336
- "assistant",
337
- opts.assistantSandwich.content,
338
- { skipIndexing: true },
339
- );
340
- await addMessage(
341
- conversation.id,
342
- "user",
343
- opts.assistantSandwich.postamble,
344
- { skipIndexing: true },
345
- );
333
+ const sandwich = opts.assistantSandwich;
334
+ let kickoffPrompt = opts.prompt;
335
+ let kickoffFromPostamble = false;
336
+ if (sandwich) {
337
+ await addMessage(conversation.id, "user", sandwich.preamble, {
338
+ skipIndexing: true,
339
+ });
340
+ await addMessage(conversation.id, "assistant", sandwich.content, {
341
+ skipIndexing: true,
342
+ });
343
+ if (opts.prompt.trim() === "") {
344
+ // persistUserMessage rejects empty content. The sandwich already
345
+ // carries the seed, so the trusted postamble is the kickoff.
346
+ kickoffFromPostamble = true;
347
+ kickoffPrompt = sandwich.postamble;
348
+ } else {
349
+ await addMessage(conversation.id, "user", sandwich.postamble, {
350
+ skipIndexing: true,
351
+ });
352
+ }
346
353
  }
347
354
 
348
- const work = processMessage(conversation.id, opts.prompt, {
355
+ const work = processMessage(conversation.id, kickoffPrompt, {
349
356
  trustContext: opts.trustContext,
350
357
  callSite: opts.callSite,
351
358
  ...(opts.overrideProfile
@@ -355,7 +362,9 @@ export async function runBackgroundJob(
355
362
  ...(opts.cronRunId ? { cronRunId: opts.cronRunId } : {}),
356
363
  ...(opts.allowedTools ? { allowedTools: opts.allowedTools } : {}),
357
364
  ...(opts.toolGateMode ? { toolGateMode: opts.toolGateMode } : {}),
358
- ...(opts.skipPromptIndexing ? { skipUserMessageIndexing: true } : {}),
365
+ ...(opts.skipPromptIndexing || kickoffFromPostamble
366
+ ? { skipUserMessageIndexing: true }
367
+ : {}),
359
368
  });
360
369
  // Absorb late rejections: if the timeout wins the race, `work` keeps
361
370
  // running and may eventually reject — swallow so it doesn't surface as
@@ -64,6 +64,16 @@ export type ProcessGuardianDecisionResult =
64
64
  ok: true;
65
65
  applied: false;
66
66
  reason: string;
67
+ /**
68
+ * Whether the decision was recorded before the decline. Present only
69
+ * for the two resolver failures: `true` under `resolver_failed` (the
70
+ * decision committed and its follow-through failed), `false` under
71
+ * `decision_not_persisted` (nothing was written; the guardian can
72
+ * retry). A client reads this rather than the reason so an older
73
+ * daemon, which reports both under `resolver_failed` and omits the
74
+ * field, is not misread as having committed.
75
+ */
76
+ committed?: boolean;
67
77
  resolverFailureReason?: string;
68
78
  requestId?: string;
69
79
  }
@@ -138,10 +148,19 @@ export async function processGuardianDecision(
138
148
  // 4. Map the canonical result
139
149
  if (decisionResult.applied) {
140
150
  if (decisionResult.resolverFailed) {
151
+ // Two failures share the primitive's `resolverFailed` flag and a
152
+ // client has to tell them apart: a decision that committed and whose
153
+ // follow-through then failed is settled (another attempt can only
154
+ // come back already resolved), while one whose persist never landed
155
+ // is still pending and is the guardian's to retry. The primitive
156
+ // marks the difference by omitting `decidedAction` when nothing was
157
+ // committed (see `decisionPersistFailure`).
158
+ const committed = decisionResult.decidedAction !== undefined;
141
159
  return {
142
160
  ok: true,
143
161
  applied: false,
144
- reason: "resolver_failed",
162
+ reason: committed ? "resolver_failed" : "decision_not_persisted",
163
+ committed,
145
164
  resolverFailureReason: decisionResult.resolverFailureReason,
146
165
  requestId: decisionResult.requestId,
147
166
  };
@@ -19,6 +19,8 @@ import { gunzipSync } from "node:zlib";
19
19
 
20
20
  import { z } from "zod";
21
21
 
22
+ import { parseUstarSizeField } from "../../archive/ustar-size.js";
23
+
22
24
  // ---------------------------------------------------------------------------
23
25
  // Manifest schema (v1)
24
26
  // ---------------------------------------------------------------------------
@@ -263,9 +265,7 @@ function parseTar(buffer: Uint8Array): TarEntry[] {
263
265
  // File type (byte 156)
264
266
  const typeFlag = String.fromCharCode(header[156]);
265
267
 
266
- // File size in octal (bytes 124-135)
267
- const sizeStr = decodeNullTerminated(header, 124, 12);
268
- const size = parseInt(sizeStr, 8) || 0;
268
+ const size = parseUstarSizeField(header);
269
269
 
270
270
  // Calculate data blocks
271
271
  const dataBlocks = Math.ceil(size / BLOCK_SIZE);
@@ -110,11 +110,10 @@ export interface PendingInteraction {
110
110
  /** When set, the host_bash request should be routed to this specific client. */
111
111
  targetClientId?: string;
112
112
  /**
113
- * Snapshot of `targetClientId`'s `actorPrincipalId` taken at registration
114
- * time. Persisted so the result-route same-actor check compares against
115
- * a stable value rather than the live hub — the target client's SSE
116
- * subscription may have briefly disconnected between dispatch and result
117
- * submission, which would otherwise 403 a legitimate result.
113
+ * Actor principal captured at registration. Targeted requests snapshot the
114
+ * target client's actor so a brief SSE reconnect does not 403 a legitimate
115
+ * result. Untargeted requests snapshot the turn's source actor so a
116
+ * different principal cannot forge stdout/stderr/exitCode.
118
117
  */
119
118
  targetActorPrincipalId?: string;
120
119
 
@@ -23,6 +23,11 @@ mock.module("../../../heartbeat/heartbeat-service.js", () => ({
23
23
  },
24
24
  }));
25
25
 
26
+ mock.module("../../../schedule/schedule-timezone.js", () => ({
27
+ resolveScheduleTimezone: (explicit: string | null | undefined) =>
28
+ explicit ?? "America/Los_Angeles",
29
+ }));
30
+
26
31
  // ─── Setup ─────────────────────────────────────────────────────────────────
27
32
 
28
33
  let workspaceDir: string;
@@ -112,3 +117,22 @@ describe("setHeartbeatConfig handler", () => {
112
117
  });
113
118
  });
114
119
  });
120
+
121
+ describe("getHeartbeatConfig handler", () => {
122
+ test("returns the resolved timezone separately from the stored override", async () => {
123
+ writeFileSync(
124
+ configPath,
125
+ JSON.stringify({ heartbeat: { enabled: true } }, null, 2) + "\n",
126
+ );
127
+ invalidateConfigCache();
128
+
129
+ const handler = findHandler("getHeartbeatConfig");
130
+ const result = (await handler({})) as {
131
+ timezone: string | null;
132
+ effectiveTimezone: string | null;
133
+ };
134
+
135
+ expect(result.timezone).toBeNull();
136
+ expect(result.effectiveTimezone).toBe("America/Los_Angeles");
137
+ });
138
+ });
@@ -34,6 +34,8 @@ const creditsHandler = ROUTES.find(
34
34
  )!.handler;
35
35
 
36
36
  const SUMMARY_URL = "https://platform.test/v1/organizations/billing/summary/";
37
+ const SUBSCRIPTION_URL =
38
+ "https://platform.test/v1/organizations/billing/subscription/";
37
39
 
38
40
  const FULL_SUMMARY = {
39
41
  settled_balance_usd: "50.00",
@@ -46,21 +48,34 @@ const FULL_SUMMARY = {
46
48
  daily_limit_snoozed: true,
47
49
  low_balance_threshold_usd: "5.00",
48
50
  low_balance_warning: true,
51
+ available_usage_balance: "9.10",
52
+ total_usage_balance: "20.00",
53
+ credits_expiring_soon_usd: "9.10",
54
+ next_credit_expiry_at: "2026-10-01T00:00:00Z",
49
55
  };
50
56
 
51
57
  const realFetch = globalThis.fetch;
52
58
  let fetchedUrls: string[] = [];
53
59
 
54
- /** Stub globalThis.fetch to answer every call with `body`, recording URLs. */
55
- function stubFetch(body: unknown, status = 200): void {
60
+ /**
61
+ * Stub globalThis.fetch: the summary URL answers with `summary`, the
62
+ * subscription URL with `subscription` (a plan, or a 500 when "error").
63
+ */
64
+ function stubFetch(
65
+ summary: unknown,
66
+ subscription: { plan_id: "base" | "pro" } | "error" = { plan_id: "pro" },
67
+ ): void {
56
68
  globalThis.fetch = (async (input: string | URL | Request) => {
57
- fetchedUrls.push(
69
+ const url =
58
70
  typeof input === "string"
59
71
  ? input
60
72
  : input instanceof URL
61
73
  ? input.toString()
62
- : input.url,
63
- );
74
+ : input.url;
75
+ fetchedUrls.push(url);
76
+ const isSubscription = url === SUBSCRIPTION_URL;
77
+ const status = isSubscription && subscription === "error" ? 500 : 200;
78
+ const body = isSubscription ? subscription : summary;
64
79
  return new Response(JSON.stringify(body), {
65
80
  status,
66
81
  headers: { "Content-Type": "application/json" },
@@ -97,6 +112,78 @@ describe("platform_credits", () => {
97
112
  daily_limit_snoozed: true,
98
113
  low_balance_threshold: 5,
99
114
  low_balance_warning: true,
115
+ plan_credit_remaining: 9.1,
116
+ plan_credit_total: 20,
117
+ plan_credit_used_fraction: 0.545,
118
+ plan_credits_spent: false,
119
+ extra_credit_remaining: 33.07,
120
+ credits_expiring_soon: 9.1,
121
+ next_credit_expiry_at: "2026-10-01T00:00:00Z",
122
+ });
123
+ });
124
+
125
+ test("marks plan credit spent once the grants are used up", async () => {
126
+ stubFetch({ ...FULL_SUMMARY, available_usage_balance: "0.00" });
127
+
128
+ expect(await creditsHandler({})).toMatchObject({
129
+ plan_credit_used_fraction: 1,
130
+ plan_credits_spent: true,
131
+ extra_credit_remaining: 42.17,
132
+ });
133
+ });
134
+
135
+ test("treats a Pro plan whose grants total nothing as fully spent", async () => {
136
+ stubFetch(
137
+ {
138
+ ...FULL_SUMMARY,
139
+ available_usage_balance: "0.00",
140
+ total_usage_balance: "0.00",
141
+ },
142
+ { plan_id: "pro" },
143
+ );
144
+
145
+ expect(await creditsHandler({})).toMatchObject({
146
+ plan_credit_total: 0,
147
+ plan_credit_used_fraction: 1,
148
+ plan_credits_spent: true,
149
+ extra_credit_remaining: 42.17,
150
+ });
151
+ expect(fetchedUrls).toEqual([SUMMARY_URL, SUBSCRIPTION_URL]);
152
+ });
153
+
154
+ test("gives a base plan with no grants no reading, and clamps a refund overshoot", async () => {
155
+ stubFetch(
156
+ {
157
+ ...FULL_SUMMARY,
158
+ available_usage_balance: "0.00",
159
+ total_usage_balance: "0.00",
160
+ },
161
+ { plan_id: "base" },
162
+ );
163
+ expect(await creditsHandler({})).toMatchObject({
164
+ plan_credit_used_fraction: null,
165
+ plan_credits_spent: null,
166
+ });
167
+
168
+ stubFetch({ ...FULL_SUMMARY, available_usage_balance: "25.00" });
169
+ expect(await creditsHandler({})).toMatchObject({
170
+ plan_credit_used_fraction: 0,
171
+ plan_credits_spent: false,
172
+ extra_credit_remaining: 17.17,
173
+ });
174
+ });
175
+
176
+ test("still reads the ratio when the subscription fetch fails, but not the zero-grant case", async () => {
177
+ stubFetch(FULL_SUMMARY, "error");
178
+ expect(await creditsHandler({})).toMatchObject({
179
+ plan_credit_used_fraction: 0.545,
180
+ plan_credits_spent: false,
181
+ });
182
+
183
+ stubFetch({ ...FULL_SUMMARY, total_usage_balance: "0.00" }, "error");
184
+ expect(await creditsHandler({})).toMatchObject({
185
+ plan_credit_used_fraction: null,
186
+ plan_credits_spent: null,
100
187
  });
101
188
  });
102
189
 
@@ -116,6 +203,13 @@ describe("platform_credits", () => {
116
203
  daily_limit_snoozed: false,
117
204
  low_balance_threshold: null,
118
205
  low_balance_warning: false,
206
+ plan_credit_remaining: null,
207
+ plan_credit_total: null,
208
+ plan_credit_used_fraction: null,
209
+ plan_credits_spent: null,
210
+ extra_credit_remaining: null,
211
+ credits_expiring_soon: null,
212
+ next_credit_expiry_at: null,
119
213
  });
120
214
  });
121
215
 
@@ -6,7 +6,7 @@
6
6
  * GET /v1/model — current model info
7
7
  * PUT /v1/model/image-gen — set image-gen model
8
8
  * GET /v1/config/embeddings — current embedding config
9
- * PUT /v1/config/embeddings — set embedding provider/model
9
+ * PUT /v1/config/embeddings - set embedding provider/model/baseUrl
10
10
  * GET /v1/config — full raw workspace config
11
11
  * PATCH /v1/config — deep-merge partial config
12
12
  * PUT /v1/config/llm/profiles/:name — replace an inference profile
@@ -29,6 +29,12 @@ import {
29
29
  LatencyBreakdownSchema,
30
30
  LLMRequestLogEntrySchema,
31
31
  } from "../../api/responses/llm-request-log-entry.js";
32
+ import {
33
+ catalogEntryFor,
34
+ type InputModalities,
35
+ modalitiesOf,
36
+ resolveModalityOverride,
37
+ } from "../../config/input-modalities.js";
32
38
  import {
33
39
  deepMergeOverwrite,
34
40
  fillContextDefaultsForMissingKeys,
@@ -85,6 +91,7 @@ import {
85
91
  CONFIG_RELOAD_DEBOUNCE_MS,
86
92
  log,
87
93
  } from "../../daemon/handlers/shared.js";
94
+ import { rescheduleHeartbeatIfTimezoneChanged } from "../../heartbeat/heartbeat-service.js";
88
95
  import {
89
96
  getAssistantMessageIdsInTurn,
90
97
  getConversation,
@@ -97,6 +104,7 @@ import {
97
104
  } from "../../persistence/conversation-types.js";
98
105
  import { getDb } from "../../persistence/db-connection.js";
99
106
  import { clearEmbeddingBackendCache } from "../../persistence/embeddings/embedding-backend.js";
107
+ import { resolveOpenAICompatibleBaseUrl } from "../../persistence/embeddings/embedding-openai.js";
100
108
  import { getLlmRequestLogSource } from "../../persistence/llm-request-log-source.js";
101
109
  import { type LogRow } from "../../persistence/llm-request-log-store.js";
102
110
  import { getMemoryRecallLogByMessageIds } from "../../plugins/defaults/memory/memory-recall-log-store.js";
@@ -112,7 +120,6 @@ import {
112
120
  listConnections,
113
121
  VELLUM_MANAGED_CONNECTION_NAME,
114
122
  } from "../../providers/inference/connections.js";
115
- import { PROVIDER_CATALOG } from "../../providers/model-catalog.js";
116
123
  import { initializeProviders } from "../../providers/registry.js";
117
124
  import { MANAGED_ROUTABLE_PROVIDERS } from "../../providers/vellum-model-routing.js";
118
125
  import { credentialKey } from "../../security/credential-key.js";
@@ -457,9 +464,11 @@ async function handleSetEmbeddingConfig({ body }: RouteHandlerArgs) {
457
464
  if (!body || typeof body !== "object") {
458
465
  throw new BadRequestError("Request body is required");
459
466
  }
460
- const { provider, model } = body as {
467
+ const { provider, model, baseUrl, dimensions } = body as {
461
468
  provider?: string;
462
469
  model?: string;
470
+ baseUrl?: string;
471
+ dimensions?: number | null;
463
472
  };
464
473
  if (!provider || typeof provider !== "string") {
465
474
  throw new BadRequestError("Missing required field: provider");
@@ -472,8 +481,32 @@ async function handleSetEmbeddingConfig({ body }: RouteHandlerArgs) {
472
481
  if (model !== undefined && typeof model !== "string") {
473
482
  throw new BadRequestError("Field 'model' must be a string");
474
483
  }
484
+ if (baseUrl !== undefined && typeof baseUrl !== "string") {
485
+ throw new BadRequestError("Field 'baseUrl' must be a string");
486
+ }
487
+ if (
488
+ typeof baseUrl === "string" &&
489
+ baseUrl !== "" &&
490
+ !resolveOpenAICompatibleBaseUrl(baseUrl)
491
+ ) {
492
+ throw new BadRequestError("Field 'baseUrl' must be an http(s) URL");
493
+ }
494
+ if (
495
+ dimensions !== undefined &&
496
+ dimensions !== null &&
497
+ (typeof dimensions !== "number" ||
498
+ !Number.isInteger(dimensions) ||
499
+ dimensions <= 0)
500
+ ) {
501
+ throw new BadRequestError(
502
+ "Field 'dimensions' must be a positive integer or null",
503
+ );
504
+ }
475
505
  try {
476
- return await setEmbeddingConfig(provider, model, getModelSetContext());
506
+ return await setEmbeddingConfig(provider, model, getModelSetContext(), {
507
+ baseUrl,
508
+ dimensions,
509
+ });
477
510
  } catch (err) {
478
511
  const message = err instanceof Error ? err.message : String(err);
479
512
  throw new InternalError(`Failed to set embedding config: ${message}`);
@@ -1064,9 +1097,10 @@ export function normalizeManagedProfileWrites(patch: unknown): void {
1064
1097
  * Annotate each profile in `config.llm.profiles` with wire-only flags
1065
1098
  * (`WIRE_ONLY_PROFILE_KEYS`) — never persisted to disk:
1066
1099
  *
1067
- * - `supportsVision`: resolved from the model catalog. Unknown (provider,
1068
- * model) pairs default to `true` (fail-open) so image upload remains
1069
- * available for custom / unlisted models.
1100
+ * - `supportsVision`: resolved from the model catalog, with a profile
1101
+ * `inputModalities.image` override winning when set. Unknown (provider,
1102
+ * model) pairs with no override default to `true` (fail-open) so image
1103
+ * upload remains available for custom / unlisted models.
1070
1104
  * - `invariant`: `true` for managed-source entries of the managed profile
1071
1105
  * names (`INVARIANT_PROFILE_NAMES`); absent otherwise. Source-gated to
1072
1106
  * match `assertInvariantProfilesPreserved` — a user-owned profile sharing
@@ -1101,9 +1135,13 @@ function enrichProfilesForWire(config: unknown): void {
1101
1135
  continue;
1102
1136
  }
1103
1137
 
1104
- const catalogProvider = PROVIDER_CATALOG.find((p) => p.id === provider);
1105
- const catalogModel = catalogProvider?.models.find((m) => m.id === model);
1106
- entry.supportsVision = catalogModel?.supportsVision ?? true;
1138
+ const catalogModel = catalogEntryFor(provider, model);
1139
+ const catalogVision = catalogModel?.supportsVision;
1140
+ const imageOverride = modalitiesOf({
1141
+ inputModalities: entry.inputModalities as InputModalities | null,
1142
+ })?.image;
1143
+ entry.supportsVision =
1144
+ resolveModalityOverride(imageOverride, catalogVision) ?? true;
1107
1145
  }
1108
1146
  }
1109
1147
 
@@ -1471,6 +1509,7 @@ export async function commitConfigWrite(
1471
1509
 
1472
1510
  clearEmbeddingBackendCache();
1473
1511
  invalidateConfigCache();
1512
+ rescheduleHeartbeatIfTimezoneChanged(preWrite, raw);
1474
1513
  // Reinitialize providers so the live registry reflects the new config.
1475
1514
  // Suppress disk writes inside loadConfig() — we just wrote the raw config
1476
1515
  // and the first-launch seed path would overwrite it with full defaults.
@@ -2344,11 +2383,14 @@ export const ROUTES: RouteDefinition[] = [
2344
2383
  allowedPrincipalTypes: ACTOR_PRINCIPALS,
2345
2384
  },
2346
2385
  summary: "Set embedding config",
2347
- description: "Change the embedding provider and optionally model.",
2386
+ description:
2387
+ "Change the embedding provider, model, and optional custom endpoint.",
2348
2388
  tags: ["config"],
2349
2389
  requestBody: z.object({
2350
2390
  provider: z.string(),
2351
2391
  model: z.string().optional(),
2392
+ baseUrl: z.string().optional(),
2393
+ dimensions: z.number().int().positive().nullable().optional(),
2352
2394
  }),
2353
2395
  handler: handleSetEmbeddingConfig,
2354
2396
  },
@@ -208,7 +208,10 @@ export const ROUTES: RouteDefinition[] = [
208
208
  operationId: "updateDocumentComment",
209
209
  endpoint: "documents/:id/comments/:commentId",
210
210
  method: "PATCH",
211
- policy: null,
211
+ policy: {
212
+ requiredScopes: ["settings.write"],
213
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
214
+ },
212
215
  summary: "Update a document comment",
213
216
  description: "Update the status or content of a comment.",
214
217
  tags: ["documents"],
@@ -273,7 +276,10 @@ export const ROUTES: RouteDefinition[] = [
273
276
  operationId: "deleteDocumentComment",
274
277
  endpoint: "documents/:id/comments/:commentId",
275
278
  method: "DELETE",
276
- policy: null,
279
+ policy: {
280
+ requiredScopes: ["settings.write"],
281
+ allowedPrincipalTypes: ACTOR_PRINCIPALS,
282
+ },
277
283
  summary: "Delete a document comment",
278
284
  description: "Permanently delete a comment.",
279
285
  tags: ["documents"],
@@ -116,6 +116,7 @@ async function handleGuardianActionDecision({
116
116
  return {
117
117
  applied: false,
118
118
  reason: result.reason,
119
+ ...(result.committed !== undefined ? { committed: result.committed } : {}),
119
120
  ...(result.resolverFailureReason
120
121
  ? { resolverFailureReason: result.resolverFailureReason }
121
122
  : {}),
@@ -271,6 +272,12 @@ export const ROUTES: RouteDefinition[] = [
271
272
  .string()
272
273
  .optional()
273
274
  .describe("Decline reason (present only when applied is false)"),
275
+ committed: z
276
+ .boolean()
277
+ .optional()
278
+ .describe(
279
+ "Whether the decision was recorded before the decline. Present only for a resolver failure: true under resolver_failed (the decision committed and its follow-through failed), false under decision_not_persisted (nothing was written; retry is safe). Absent on daemons that report both under resolver_failed.",
280
+ ),
274
281
  replyText: z
275
282
  .string()
276
283
  .optional()