@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
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Personality slider sidecar path, the five 0-100 axis ids, and the
3
+ * hosted-Qwen direction poles those sliders map onto.
4
+ *
5
+ * The web writes `data/personality-sliders.json`. The assistant reads the
6
+ * same path and maps each slider onto one left/right pole. Both import
7
+ * these constants from `@vellumai/assistant-api` so the ids cannot drift.
8
+ */
9
+
10
+ /** Workspace-relative sidecar holding the five 0-100 slider values. */
11
+ export const PERSONALITY_SLIDERS_PATH = "data/personality-sliders.json";
12
+
13
+ /** Sliders start centered. Center is no steer on the hosted-Qwen route. */
14
+ export const PERSONALITY_SLIDER_DEFAULT = 50;
15
+
16
+ /**
17
+ * Axis ids the personality sliders key their values by. Each is 0-100 with
18
+ * 0 = the left label and 100 = the right label.
19
+ */
20
+ export const PERSONALITY_AXIS_IDS = {
21
+ companionCoworker: "companion-coworker",
22
+ genzBoomer: "genz-boomer",
23
+ executeCollaborate: "execute-collaborate",
24
+ playfulSerious: "playful-serious",
25
+ politeUnfiltered: "polite-unfiltered",
26
+ } as const;
27
+
28
+ export type PersonalityAxisId =
29
+ (typeof PERSONALITY_AXIS_IDS)[keyof typeof PERSONALITY_AXIS_IDS];
30
+
31
+ /**
32
+ * One-sided direction poles for each slider. Center (50) activates neither.
33
+ * Values below 50 activate `left`; values above 50 activate `right`.
34
+ */
35
+ export const PERSONALITY_DIRECTION_AXES = [
36
+ {
37
+ sliderId: PERSONALITY_AXIS_IDS.companionCoworker,
38
+ left: "companion",
39
+ right: "coworker",
40
+ },
41
+ {
42
+ sliderId: PERSONALITY_AXIS_IDS.genzBoomer,
43
+ left: "genz",
44
+ right: "boomer",
45
+ },
46
+ {
47
+ sliderId: PERSONALITY_AXIS_IDS.executeCollaborate,
48
+ left: "independent",
49
+ right: "collaborative",
50
+ },
51
+ {
52
+ sliderId: PERSONALITY_AXIS_IDS.playfulSerious,
53
+ left: "playful",
54
+ right: "serious",
55
+ },
56
+ {
57
+ sliderId: PERSONALITY_AXIS_IDS.politeUnfiltered,
58
+ left: "polite",
59
+ right: "unfiltered",
60
+ },
61
+ ] as const;
62
+
63
+ export type PersonalityDirectionAxis =
64
+ (typeof PERSONALITY_DIRECTION_AXES)[number];
package/src/api/index.ts CHANGED
@@ -153,6 +153,14 @@ export {
153
153
  type DocumentMutationTool,
154
154
  REOPENABLE_DOCUMENT_MUTATION_TOOL_NAMES,
155
155
  } from "./constants/document-tools.js";
156
+ export {
157
+ PERSONALITY_AXIS_IDS,
158
+ PERSONALITY_DIRECTION_AXES,
159
+ PERSONALITY_SLIDER_DEFAULT,
160
+ PERSONALITY_SLIDERS_PATH,
161
+ type PersonalityAxisId,
162
+ type PersonalityDirectionAxis,
163
+ } from "./constants/personality-sliders.js";
156
164
  export {
157
165
  MIN_INPUT_RESERVE_TOKENS,
158
166
  type ProfileConfigIssue,
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { MALFORMED_USTAR_SIZE, parseUstarSizeField } from "./ustar-size.js";
4
+
5
+ function headerWithSize(field: string): Uint8Array {
6
+ const header = new Uint8Array(512);
7
+ const bytes = Buffer.from(field, "utf-8");
8
+ header.set(bytes.subarray(0, 12), 124);
9
+ return header;
10
+ }
11
+
12
+ describe("parseUstarSizeField", () => {
13
+ test("reads a NUL-terminated octal size", () => {
14
+ expect(parseUstarSizeField(headerWithSize("00000001000\0"))).toBe(512);
15
+ });
16
+
17
+ test("treats an empty field as zero", () => {
18
+ expect(parseUstarSizeField(headerWithSize("\0".repeat(12)))).toBe(0);
19
+ });
20
+
21
+ test("rejects a negative octal size", () => {
22
+ expect(() => parseUstarSizeField(headerWithSize("-0000001000\0"))).toThrow(
23
+ MALFORMED_USTAR_SIZE,
24
+ );
25
+ });
26
+
27
+ test("rejects a truncated header", () => {
28
+ expect(() => parseUstarSizeField(new Uint8Array(100))).toThrow(
29
+ MALFORMED_USTAR_SIZE,
30
+ );
31
+ });
32
+ });
@@ -0,0 +1,23 @@
1
+ export const MALFORMED_USTAR_SIZE = "malformed tar size field";
2
+
3
+ /**
4
+ * Parse the ustar `size` field (bytes 124-135, octal, NUL or space
5
+ * terminated). Rejects non-finite and negative values so a crafted header
6
+ * cannot walk the archive cursor backwards.
7
+ */
8
+ export function parseUstarSizeField(header: Uint8Array): number {
9
+ if (header.length < 136) {
10
+ throw new Error(MALFORMED_USTAR_SIZE);
11
+ }
12
+ const raw = header.subarray(124, 136);
13
+ let end = 0;
14
+ while (end < raw.length && raw[end] !== 0) {
15
+ end++;
16
+ }
17
+ const sizeStr = new TextDecoder().decode(raw.subarray(0, end)).trim();
18
+ const size = sizeStr ? Number.parseInt(sizeStr, 8) : 0;
19
+ if (!Number.isFinite(size) || size < 0) {
20
+ throw new Error(MALFORMED_USTAR_SIZE);
21
+ }
22
+ return size;
23
+ }
@@ -1,6 +1,7 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import { setConfig } from "../__tests__/helpers/set-config.js";
4
+ import { hasSetConstructs } from "../schedule/recurrence-engine.js";
4
5
 
5
6
  type MockHeartbeatConfig = {
6
7
  enabled: boolean;
@@ -44,6 +45,7 @@ mock.module("../heartbeat/heartbeat-service.js", () => ({
44
45
  }));
45
46
 
46
47
  mock.module("../schedule/recurrence-engine.js", () => ({
48
+ hasSetConstructs,
47
49
  computeNextRunAt: () => computedCronNextRunAt,
48
50
  }));
49
51
 
@@ -80,6 +82,7 @@ describe("computeNextBackgroundWakeIntent", () => {
80
82
  heartbeatConsecutiveRunCapReached = false;
81
83
  schedules = [];
82
84
  computedCronNextRunAt = NOW + 3_600_000;
85
+ setConfig("ui", {});
83
86
  });
84
87
 
85
88
  test("returns heartbeat-only interval wake intent", () => {
@@ -109,6 +112,16 @@ describe("computeNextBackgroundWakeIntent", () => {
109
112
  expect(intent!.reason).toBe("heartbeat");
110
113
  });
111
114
 
115
+ test("resolves heartbeat timezone from the user zone when unset", () => {
116
+ setConfig("ui", { detectedTimezone: "America/Los_Angeles" });
117
+ const intent = computeWakeIntent(NOW);
118
+ expect(intent).not.toBeNull();
119
+ expect(intent!.sourcePayload.heartbeat!.timezone).toBe(
120
+ "America/Los_Angeles",
121
+ );
122
+ setConfig("ui", {});
123
+ });
124
+
112
125
  test("returns heartbeat-only cron wake intent", () => {
113
126
  heartbeatConfig.cronExpression = "0 9 * * *";
114
127
  heartbeatConfig.timezone = "America/New_York";
@@ -4,6 +4,7 @@ import { getConfig } from "../config/loader.js";
4
4
  import { HeartbeatService } from "../heartbeat/heartbeat-service.js";
5
5
  import { computeNextRunAt } from "../schedule/recurrence-engine.js";
6
6
  import { listSchedules, type ScheduleJob } from "../schedule/schedule-store.js";
7
+ import { resolveScheduleTimezone } from "../schedule/schedule-timezone.js";
7
8
 
8
9
  export type BackgroundWakeIntentReason = "heartbeat" | "schedule" | "mixed";
9
10
 
@@ -110,6 +111,7 @@ function getHeartbeatWakeSource(now: number): HeartbeatWakeSource | null {
110
111
  const serviceNextRunAt = service?.nextRunAt ?? null;
111
112
  let nextRunAt = serviceNextRunAt;
112
113
  const mode = config.cronExpression != null ? "cron" : "interval";
114
+ const timezone = resolveScheduleTimezone(config.timezone);
113
115
 
114
116
  if (nextRunAt == null) {
115
117
  if (config.cronExpression != null) {
@@ -118,7 +120,7 @@ function getHeartbeatWakeSource(now: number): HeartbeatWakeSource | null {
118
120
  {
119
121
  syntax: "cron",
120
122
  expression: config.cronExpression,
121
- timezone: config.timezone,
123
+ timezone,
122
124
  },
123
125
  now,
124
126
  );
@@ -139,7 +141,7 @@ function getHeartbeatWakeSource(now: number): HeartbeatWakeSource | null {
139
141
  mode,
140
142
  intervalMs: config.intervalMs,
141
143
  cronExpression: config.cronExpression,
142
- timezone: config.timezone,
144
+ timezone,
143
145
  activeHoursStart: config.activeHoursStart,
144
146
  activeHoursEnd: config.activeHoursEnd,
145
147
  maxConsecutiveRuns: config.maxConsecutiveRuns,
@@ -29,6 +29,7 @@ import { resolveChannelCapabilities } from "../daemon/conversation-runtime-assem
29
29
  import { getOrCreateConversation } from "../daemon/conversation-store.js";
30
30
  import type { TrustContext } from "../daemon/trust-context-types.js";
31
31
  import {
32
+ newestPersistedSightFrame,
32
33
  pendingStandaloneImagePersist,
33
34
  SIGHT_FRAME_TURN_HOLD_MS,
34
35
  } from "../live-voice/live-voice-photo.js";
@@ -803,6 +804,10 @@ export async function startVoiceTurn(
803
804
  conversationReadyAt: 0,
804
805
  admissionClearAt: 0,
805
806
  sightHoldMs: 0,
807
+ // The camera frame this turn reads as the current view, and how old it
808
+ // was when the turn's own message landed. Null when the conversation
809
+ // carries no frame.
810
+ newestSightFrame: null as { attachmentId: string; ageMs: number } | null,
806
811
  persistDoneAt: 0,
807
812
  };
808
813
  const eventSink: VoiceRunEventSink = {
@@ -1425,6 +1430,26 @@ export async function startVoiceTurn(
1425
1430
  }
1426
1431
  }
1427
1432
  dispatch.persistDoneAt = Date.now();
1433
+ // Read now, under the flag this turn holds, rather than after the sight
1434
+ // hold: a frame can still land between the hold and the persist, when its
1435
+ // acquire beats this turn's and the turn retries behind it. What the loop
1436
+ // below reads is what the rows hold at this moment, so this is the frame the
1437
+ // answer is about. One indexed row; a log-only read that fails must not
1438
+ // take the turn down with it.
1439
+ try {
1440
+ const newestFrame = newestPersistedSightFrame(opts.conversationId);
1441
+ if (newestFrame) {
1442
+ dispatch.newestSightFrame = {
1443
+ attachmentId: newestFrame.attachmentId,
1444
+ ageMs: dispatch.persistDoneAt - newestFrame.capturedAt,
1445
+ };
1446
+ }
1447
+ } catch (err) {
1448
+ log.warn(
1449
+ { err, turnId, conversationId: opts.conversationId },
1450
+ "Could not read the newest camera frame for the dispatch timing log",
1451
+ );
1452
+ }
1428
1453
  try {
1429
1454
  opts.callbacks?.persisted_user_message_id?.(messageId);
1430
1455
  } catch (err) {
@@ -1851,6 +1876,7 @@ export async function startVoiceTurn(
1851
1876
  admissionWaitMs:
1852
1877
  dispatch.admissionClearAt - dispatch.conversationReadyAt,
1853
1878
  sightHoldMs: dispatch.sightHoldMs,
1879
+ newestSightFrame: dispatch.newestSightFrame,
1854
1880
  persistMs:
1855
1881
  dispatch.persistDoneAt -
1856
1882
  dispatch.admissionClearAt -
@@ -33,6 +33,7 @@ describe("catalog search help for setup-intent retrieval", () => {
33
33
  expect(indexed).toContain("Setup <app> for me");
34
34
  expect(indexed.toLowerCase()).toContain("before searching the web");
35
35
  expect(indexed).toContain("assistant plugins search");
36
+ expect(indexed).toContain("assistant plugins install <name>");
36
37
  expect(indexed).toContain("assistant skills search");
37
38
  expect(indexed.toLowerCase()).toContain("channels");
38
39
  expect(pluginsHelp.description).toContain("channels");
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Tests for `assistant plugins search` human vs `--json` output.
3
+ *
4
+ * Search results include a `github:owner/repo@ref` PATH column. The model
5
+ * copies that locator into `plugins install`. The human footer tells it to
6
+ * install by marketplace name instead.
7
+ */
8
+
9
+ import { describe, expect, mock, test } from "bun:test";
10
+
11
+ import { PLUGINS_SEARCH_INSTALL_HINT } from "../plugins.help.js";
12
+ import { runCliCommand } from "./cli-test-harness.js";
13
+
14
+ const catalog = {
15
+ ref: "test-ref",
16
+ matches: [
17
+ {
18
+ name: "imessage",
19
+ path: "github:vellum-ai/imessage@abc123",
20
+ category: "productivity",
21
+ source: {
22
+ kind: "github" as const,
23
+ repo: "vellum-ai/imessage",
24
+ ref: "abc123",
25
+ },
26
+ },
27
+ ],
28
+ };
29
+
30
+ mock.module("../../lib/plugin-catalog-cache.js", () => ({
31
+ getPluginCatalog: async () => catalog,
32
+ }));
33
+
34
+ const { registerPluginsCommand } = await import("../plugins.js");
35
+
36
+ function runSearch(args: string[]): Promise<{
37
+ stdout: string;
38
+ exitCode: number;
39
+ }> {
40
+ return runCliCommand(registerPluginsCommand, ["plugins", ...args]);
41
+ }
42
+
43
+ describe("plugins search", () => {
44
+ test("human output ends with the marketplace-name install hint", async () => {
45
+ const { exitCode, stdout } = await runSearch(["search", "imessage"]);
46
+
47
+ expect(exitCode).toBe(0);
48
+ expect(stdout).toContain("imessage");
49
+ expect(stdout).toContain("github:vellum-ai/imessage@abc123");
50
+ expect(stdout).toContain('1 match for "imessage".');
51
+ expect(stdout.trimEnd().endsWith(PLUGINS_SEARCH_INSTALL_HINT)).toBe(true);
52
+ expect(PLUGINS_SEARCH_INSTALL_HINT).toContain(
53
+ "assistant plugins install <name>",
54
+ );
55
+ });
56
+
57
+ test("empty human result has no install hint", async () => {
58
+ const { exitCode, stdout } = await runSearch(["search", "nothing-matches"]);
59
+
60
+ expect(exitCode).toBe(0);
61
+ expect(stdout).toContain('No plugins matched "nothing-matches".');
62
+ expect(stdout).not.toContain(PLUGINS_SEARCH_INSTALL_HINT);
63
+ });
64
+
65
+ test("json output omits the install hint", async () => {
66
+ const { exitCode, stdout } = await runSearch([
67
+ "search",
68
+ "imessage",
69
+ "--json",
70
+ ]);
71
+
72
+ expect(exitCode).toBe(0);
73
+ expect(stdout).not.toContain(PLUGINS_SEARCH_INSTALL_HINT);
74
+ const parsed = JSON.parse(stdout) as {
75
+ query: string;
76
+ matches: Array<{ name: string }>;
77
+ };
78
+ expect(parsed.query).toBe("imessage");
79
+ expect(parsed.matches.map((m) => m.name)).toEqual(["imessage"]);
80
+ });
81
+ });
@@ -51,7 +51,9 @@ API Keys (agent shells refuse inline keys unless --generated is passed).
51
51
 
52
52
  Examples:
53
53
  $ assistant config set llm.defaultProvider.provider anthropic
54
- $ assistant config set calls.enabled true`,
54
+ $ assistant config set calls.enabled true
55
+ $ assistant config set memory.embeddings.provider custom
56
+ $ assistant config set memory.embeddings.baseUrl https://gateway.example.com/v1`,
55
57
  },
56
58
  {
57
59
  name: "get",
@@ -35,7 +35,7 @@ interface StopResponse {
35
35
  interface EmbeddingStatus {
36
36
  enabled: boolean;
37
37
  degraded: boolean;
38
- provider: "local" | "openai" | "gemini" | "ollama" | null;
38
+ provider: "local" | "openai" | "gemini" | "ollama" | "custom" | null;
39
39
  model: string | null;
40
40
  reason: string | null;
41
41
  }
@@ -18,11 +18,29 @@ const BASE: PlatformCreditsResult = {
18
18
  daily_limit_snoozed: false,
19
19
  low_balance_threshold: 5,
20
20
  low_balance_warning: false,
21
+ plan_credit_remaining: 9.1,
22
+ plan_credit_total: 20,
23
+ plan_credit_used_fraction: 0.545,
24
+ plan_credits_spent: false,
25
+ extra_credit_remaining: 33.07,
26
+ credits_expiring_soon: 9.1,
27
+ next_credit_expiry_at: "2026-10-01T00:00:00Z",
28
+ };
29
+
30
+ const NO_GRANTS: PlatformCreditsResult = {
31
+ ...BASE,
32
+ plan_credit_remaining: null,
33
+ plan_credit_total: null,
34
+ plan_credit_used_fraction: null,
35
+ plan_credits_spent: null,
36
+ extra_credit_remaining: null,
37
+ credits_expiring_soon: null,
38
+ next_credit_expiry_at: null,
21
39
  };
22
40
 
23
41
  describe("formatCreditsLines", () => {
24
42
  test("labels daily spend as counted against the daily limit, never as total spend", () => {
25
- const lines = formatCreditsLines(BASE);
43
+ const lines = formatCreditsLines(NO_GRANTS);
26
44
 
27
45
  expect(lines[2]).toBe(
28
46
  "Today: $3.25 counted against the $10.00 daily limit",
@@ -31,20 +49,20 @@ describe("formatCreditsLines", () => {
31
49
  });
32
50
 
33
51
  test("says when no daily limit is set and flags a reached or skipped limit", () => {
34
- expect(formatCreditsLines({ ...BASE, daily_limit: null })[2]).toBe(
52
+ expect(formatCreditsLines({ ...NO_GRANTS, daily_limit: null })[2]).toBe(
35
53
  "Today: $3.25 counted against the daily limit (none set)",
36
54
  );
37
55
  expect(
38
- formatCreditsLines({ ...BASE, daily_limit_reached: true })[2],
56
+ formatCreditsLines({ ...NO_GRANTS, daily_limit_reached: true })[2],
39
57
  ).toEndWith(" (limit reached)");
40
58
  expect(
41
- formatCreditsLines({ ...BASE, daily_limit_snoozed: true })[2],
59
+ formatCreditsLines({ ...NO_GRANTS, daily_limit_snoozed: true })[2],
42
60
  ).toEndWith(" (limit skipped for today)");
43
61
  });
44
62
 
45
63
  test("omits the daily line without daily spend and adds the low-balance warning", () => {
46
64
  const lines = formatCreditsLines({
47
- ...BASE,
65
+ ...NO_GRANTS,
48
66
  daily_spend: null,
49
67
  low_balance_warning: true,
50
68
  });
@@ -55,6 +73,77 @@ describe("formatCreditsLines", () => {
55
73
  );
56
74
  });
57
75
 
76
+ test("reports plan credit left with the meter percentage, extra credit, and expiry", () => {
77
+ const lines = formatCreditsLines(BASE);
78
+
79
+ expect(lines[2]).toBe(
80
+ "Plan: $9.10 of $20.00 plan credit left (55% used)",
81
+ );
82
+ expect(lines[3]).toBe(
83
+ "Extra: $33.07 bought or earned on top of plan credit",
84
+ );
85
+ expect(lines[4]).toBe(
86
+ "Expiring: $9.10 within 30 days (next expiry 2026-10-01T00:00:00Z)",
87
+ );
88
+ expect(lines[5]).toStartWith("Today: $3.25 counted against");
89
+ });
90
+
91
+ test("says plan credit is used up and skips the expiry line when nothing is scheduled", () => {
92
+ const spent = {
93
+ ...BASE,
94
+ plan_credit_remaining: 0,
95
+ plan_credit_used_fraction: 1,
96
+ plan_credits_spent: true,
97
+ credits_expiring_soon: 0,
98
+ next_credit_expiry_at: null,
99
+ };
100
+ const lines = formatCreditsLines({
101
+ ...spent,
102
+ extra_credit_remaining: 42.17,
103
+ });
104
+
105
+ expect(lines[2]).toBe(
106
+ "Plan: plan credit used up or expired; managed usage now draws on extra credit",
107
+ );
108
+ expect(lines[3]).toBe(
109
+ "Extra: $42.17 bought or earned on top of plan credit",
110
+ );
111
+ expect(lines[4]).toStartWith("Today:");
112
+
113
+ expect(formatCreditsLines({ ...spent, extra_credit_remaining: 0 })[2]).toBe(
114
+ "Plan: plan credit used up or expired, and no extra credit remains",
115
+ );
116
+ });
117
+
118
+ test("reports the spent state with an unknown wallet when the remaining amount is omitted", () => {
119
+ const lines = formatCreditsLines({
120
+ ...BASE,
121
+ plan_credit_remaining: null,
122
+ plan_credit_total: 0,
123
+ plan_credit_used_fraction: 1,
124
+ plan_credits_spent: true,
125
+ extra_credit_remaining: null,
126
+ });
127
+
128
+ expect(lines[2]).toBe(
129
+ "Plan: plan credit used up or expired; whether extra credit remains was not reported",
130
+ );
131
+ });
132
+
133
+ test("shows the next expiry when it falls beyond the 30-day window", () => {
134
+ const lines = formatCreditsLines({ ...BASE, credits_expiring_soon: 0 });
135
+
136
+ expect(lines[4]).toBe(
137
+ "Expiry: next credit expiry 2026-10-01T00:00:00Z (nothing expires within 30 days)",
138
+ );
139
+ });
140
+
141
+ test("does not claim an empty 30-day window when that figure was not reported", () => {
142
+ const lines = formatCreditsLines({ ...BASE, credits_expiring_soon: null });
143
+
144
+ expect(lines[4]).toBe("Expiry: next credit expiry 2026-10-01T00:00:00Z");
145
+ });
146
+
58
147
  test("formats a negative remaining balance with the sign first", () => {
59
148
  expect(formatCreditsLines({ ...BASE, remaining: -2.5 })[0]).toStartWith(
60
149
  "Remaining: -$2.50 USD",
@@ -22,6 +22,13 @@ describe("assistant platform credits", () => {
22
22
  daily_limit_snoozed: false,
23
23
  low_balance_threshold: 5,
24
24
  low_balance_warning: false,
25
+ plan_credit_remaining: 9.1,
26
+ plan_credit_total: 20,
27
+ plan_credit_used_fraction: 0.545,
28
+ plan_credits_spent: false,
29
+ extra_credit_remaining: 33.07,
30
+ credits_expiring_soon: 9.1,
31
+ next_credit_expiry_at: "2026-10-01T00:00:00Z",
25
32
  },
26
33
  };
27
34
  });
@@ -41,6 +48,9 @@ describe("assistant platform credits", () => {
41
48
  expect(parsed.daily_limit).toBe(10);
42
49
  expect(parsed.daily_limit_reached).toBe(false);
43
50
  expect(parsed.low_balance_warning).toBe(false);
51
+ expect(parsed.plan_credit_remaining).toBe(9.1);
52
+ expect(parsed.plan_credit_used_fraction).toBe(0.545);
53
+ expect(parsed.extra_credit_remaining).toBe(33.07);
44
54
  });
45
55
 
46
56
  test("plain text mode does not emit JSON to stdout", async () => {
@@ -13,6 +13,13 @@ export interface PlatformCreditsResult {
13
13
  daily_limit_snoozed: boolean;
14
14
  low_balance_threshold: number | null;
15
15
  low_balance_warning: boolean;
16
+ plan_credit_remaining: number | null;
17
+ plan_credit_total: number | null;
18
+ plan_credit_used_fraction: number | null;
19
+ plan_credits_spent: boolean | null;
20
+ extra_credit_remaining: number | null;
21
+ credits_expiring_soon: number | null;
22
+ next_credit_expiry_at: string | null;
16
23
  }
17
24
 
18
25
  /** Human-readable lines for `assistant platform credits`. */
@@ -22,6 +29,56 @@ export function formatCreditsLines(result: PlatformCreditsResult): string[] {
22
29
  `Remaining: ${formatCostUsd(result.remaining)} ${result.unit} (as of ${result.as_of})${staleNote}`,
23
30
  `Settled: ${formatCostUsd(result.settled)} Pending: ${formatCostUsd(result.pending)}`,
24
31
  ];
32
+ if (result.plan_credits_spent === true) {
33
+ // Mirrors the web usage panel: extra credit is only said to fund usage
34
+ // once the wallet provably holds some, and an unreported wallet is not
35
+ // an empty one.
36
+ const extra = result.extra_credit_remaining;
37
+ lines.push(
38
+ extra === null
39
+ ? "Plan: plan credit used up or expired; whether extra credit remains was not reported"
40
+ : extra > 0
41
+ ? "Plan: plan credit used up or expired; managed usage now draws on extra credit"
42
+ : "Plan: plan credit used up or expired, and no extra credit remains",
43
+ );
44
+ } else if (
45
+ result.plan_credit_remaining !== null &&
46
+ result.plan_credit_total !== null
47
+ ) {
48
+ const pct =
49
+ result.plan_credit_used_fraction === null
50
+ ? ""
51
+ : ` (${Math.round(result.plan_credit_used_fraction * 100)}% used)`;
52
+ lines.push(
53
+ `Plan: ${formatCostUsd(result.plan_credit_remaining)} of ${formatCostUsd(result.plan_credit_total)} plan credit left${pct}`,
54
+ );
55
+ }
56
+ if (result.extra_credit_remaining !== null) {
57
+ lines.push(
58
+ `Extra: ${formatCostUsd(result.extra_credit_remaining)} bought or earned on top of plan credit`,
59
+ );
60
+ }
61
+ if (
62
+ result.credits_expiring_soon !== null &&
63
+ result.credits_expiring_soon > 0
64
+ ) {
65
+ const when = result.next_credit_expiry_at
66
+ ? ` (next expiry ${result.next_credit_expiry_at})`
67
+ : "";
68
+ lines.push(
69
+ `Expiring: ${formatCostUsd(result.credits_expiring_soon)} within 30 days${when}`,
70
+ );
71
+ } else if (result.next_credit_expiry_at) {
72
+ // A null 30-day figure means the platform did not report it, so only an
73
+ // actual zero earns the "nothing expires within 30 days" claim.
74
+ const window =
75
+ result.credits_expiring_soon === null
76
+ ? ""
77
+ : " (nothing expires within 30 days)";
78
+ lines.push(
79
+ `Expiry: next credit expiry ${result.next_credit_expiry_at}${window}`,
80
+ );
81
+ }
25
82
  if (result.daily_spend !== null) {
26
83
  const limit =
27
84
  result.daily_limit === null
@@ -72,10 +72,11 @@ Examples:
72
72
  {
73
73
  name: "credits",
74
74
  description:
75
- "Show the organization's remaining credit balance and daily-limit state",
75
+ "Show the organization's credit balance, plan-credit usage, and daily-limit state",
76
76
  helpText: `
77
- Fetches the org's credit balance, today's spend, and daily-limit state from
78
- the platform billing summary.
77
+ Fetches the org's credit balance, plan-included credit usage, extra credit,
78
+ upcoming expiry, today's spend, and daily-limit state from the platform
79
+ billing summary.
79
80
 
80
81
  Fields:
81
82
  remaining Effective balance (settled minus pending charges) in USD
@@ -91,6 +92,21 @@ Fields:
91
92
  daily_limit_snoozed True when the limit is skipped for the rest of today
92
93
  low_balance_threshold Low-balance alert threshold in USD
93
94
  low_balance_warning True when the balance is below the threshold and auto top-up is off
95
+ plan_credit_remaining Unused plan-included credit (initial credit, Pro bundle) in USD
96
+ plan_credit_total What those plan-included grants were worth in total, in USD
97
+ plan_credit_used_fraction
98
+ Share of plan credit used, 0 to 1 (the in-app usage meter)
99
+ plan_credits_spent True when plan credit is used up or expired (extra_credit_remaining
100
+ says whether anything still funds usage); null with no reading
101
+ extra_credit_remaining Credit bought or earned on top of plan credit, in USD
102
+ credits_expiring_soon Credit on grants expiring within 30 days, in USD
103
+ next_credit_expiry_at Earliest upcoming grant expiry (ISO 8601), or null
104
+
105
+ Grant fields are null when the platform does not report grant figures. Plan
106
+ credit mixes grants with different lifetimes (only the Pro bundle turns over
107
+ with the billing cycle), so it has no single reset date. next_credit_expiry_at
108
+ is the earliest expiry across all grants, plan-included or not, and
109
+ 'assistant platform subscription' gives the billing cycle boundary.
94
110
 
95
111
  Requires platform credentials (run 'assistant platform connect' first or
96
112
  ensure VELLUM_PLATFORM_URL is set and credentials are stored).
@@ -8,6 +8,9 @@ import {
8
8
  PLUGIN_UPGRADE_STRATEGIES,
9
9
  } from "../lib/plugin-constants.js";
10
10
 
11
+ export const PLUGINS_SEARCH_INSTALL_HINT =
12
+ "Install the plugin with `assistant plugins install <name>`.";
13
+
11
14
  export const pluginsHelp: CliCommandHelp = {
12
15
  name: "plugins",
13
16
  description:
@@ -166,7 +169,7 @@ Arguments:
166
169
  user sentence. Use the product or plugin name. Anchors like
167
170
  ^example work.
168
171
 
169
- If a match is found, install it with 'assistant plugins install <name>'.
172
+ ${PLUGINS_SEARCH_INSTALL_HINT}
170
173
  If nothing matches, try 'assistant skills search <query>', then web search.
171
174
 
172
175
  Examples: