@vellumai/assistant 0.5.3 → 0.5.5

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 (111) hide show
  1. package/Dockerfile +18 -27
  2. package/docs/architecture/memory.md +105 -0
  3. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  4. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/archive-recall.test.ts +560 -0
  7. package/src/__tests__/conversation-clear-safety.test.ts +259 -0
  8. package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
  11. package/src/__tests__/memory-reducer-job.test.ts +538 -0
  12. package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
  13. package/src/__tests__/memory-reducer-types.test.ts +12 -4
  14. package/src/__tests__/memory-reducer.test.ts +7 -1
  15. package/src/__tests__/memory-regressions.test.ts +24 -4
  16. package/src/__tests__/memory-simplified-config.test.ts +4 -4
  17. package/src/__tests__/openai-whisper.test.ts +93 -0
  18. package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
  19. package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
  20. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  21. package/src/__tests__/volume-security-guard.test.ts +155 -0
  22. package/src/cli/commands/conversations.ts +18 -0
  23. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  24. package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
  25. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  26. package/src/config/env-registry.ts +9 -0
  27. package/src/config/feature-flag-registry.json +8 -0
  28. package/src/config/loader.ts +0 -1
  29. package/src/config/schemas/memory-simplified.ts +1 -1
  30. package/src/credential-execution/managed-catalog.ts +5 -15
  31. package/src/daemon/config-watcher.ts +4 -1
  32. package/src/daemon/conversation-memory.ts +117 -0
  33. package/src/daemon/conversation-runtime-assembly.ts +1 -0
  34. package/src/daemon/daemon-control.ts +7 -0
  35. package/src/daemon/handlers/conversations.ts +11 -0
  36. package/src/daemon/lifecycle.ts +51 -2
  37. package/src/daemon/providers-setup.ts +2 -1
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/archive-recall.ts +516 -0
  41. package/src/memory/brief-time.ts +5 -4
  42. package/src/memory/conversation-crud.ts +210 -0
  43. package/src/memory/conversation-key-store.ts +33 -4
  44. package/src/memory/db-init.ts +4 -0
  45. package/src/memory/embedding-local.ts +11 -5
  46. package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
  47. package/src/memory/job-handlers/conversation-starters.ts +24 -30
  48. package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
  49. package/src/memory/jobs-store.ts +2 -0
  50. package/src/memory/jobs-worker.ts +8 -0
  51. package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
  52. package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
  53. package/src/memory/migrations/141-rename-verification-table.ts +8 -0
  54. package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
  55. package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
  56. package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
  57. package/src/memory/migrations/index.ts +1 -0
  58. package/src/memory/reducer-scheduler.ts +242 -0
  59. package/src/memory/reducer-types.ts +9 -2
  60. package/src/memory/reducer.ts +25 -11
  61. package/src/memory/schema/infrastructure.ts +1 -0
  62. package/src/messaging/provider.ts +9 -0
  63. package/src/messaging/providers/slack/adapter.ts +29 -2
  64. package/src/oauth/connection-resolver.test.ts +22 -18
  65. package/src/oauth/connection-resolver.ts +92 -7
  66. package/src/oauth/platform-connection.test.ts +78 -69
  67. package/src/oauth/platform-connection.ts +12 -19
  68. package/src/permissions/trust-client.ts +343 -0
  69. package/src/permissions/trust-store-interface.ts +105 -0
  70. package/src/permissions/trust-store.ts +523 -36
  71. package/src/platform/client.test.ts +148 -0
  72. package/src/platform/client.ts +71 -0
  73. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  74. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  75. package/src/providers/speech-to-text/resolve.ts +9 -0
  76. package/src/providers/speech-to-text/types.ts +17 -0
  77. package/src/runtime/auth/route-policy.ts +10 -1
  78. package/src/runtime/http-server.ts +2 -2
  79. package/src/runtime/routes/conversation-management-routes.ts +88 -2
  80. package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
  81. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  82. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  83. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  84. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  85. package/src/runtime/routes/log-export-routes.ts +1 -0
  86. package/src/runtime/routes/secret-routes.ts +5 -1
  87. package/src/schedule/schedule-store.ts +7 -0
  88. package/src/schedule/scheduler.ts +6 -2
  89. package/src/security/ces-credential-client.ts +173 -0
  90. package/src/security/secure-keys.ts +65 -22
  91. package/src/signals/bash.ts +3 -0
  92. package/src/signals/cancel.ts +3 -0
  93. package/src/signals/confirm.ts +3 -0
  94. package/src/signals/conversation-undo.ts +3 -0
  95. package/src/signals/event-stream.ts +7 -0
  96. package/src/signals/shotgun.ts +3 -0
  97. package/src/signals/trust-rule.ts +3 -0
  98. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  99. package/src/telemetry/usage-telemetry-reporter.ts +22 -20
  100. package/src/tools/filesystem/edit.ts +6 -1
  101. package/src/tools/filesystem/read.ts +6 -1
  102. package/src/tools/filesystem/write.ts +6 -1
  103. package/src/tools/memory/handlers.ts +129 -1
  104. package/src/tools/schedule/create.ts +3 -0
  105. package/src/tools/schedule/list.ts +5 -1
  106. package/src/tools/schedule/update.ts +6 -0
  107. package/src/util/device-id.ts +70 -7
  108. package/src/util/logger.ts +35 -9
  109. package/src/util/platform.ts +29 -5
  110. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  111. package/src/workspace/migrations/registry.ts +2 -0
@@ -0,0 +1,173 @@
1
+ /**
2
+ * HTTP client for CES credential CRUD endpoints.
3
+ *
4
+ * In containerized mode the assistant cannot access `keys.enc` directly.
5
+ * Instead, the CES sidecar exposes credential management over HTTP and the
6
+ * assistant talks to it via this client.
7
+ *
8
+ * Endpoints (served by `credential-executor/src/http/credential-routes.ts`):
9
+ * - GET /v1/credentials → { accounts: string[] }
10
+ * - GET /v1/credentials/:account → { account, value } | 404
11
+ * - POST /v1/credentials/:account → { ok: true, account }
12
+ * - DELETE /v1/credentials/:account → { ok: true, account } | 404 | 500
13
+ *
14
+ * Auth: Bearer token from `CES_SERVICE_TOKEN` env var.
15
+ * Base URL: `CES_CREDENTIAL_URL` env var (e.g. `http://ces-container:8090`).
16
+ */
17
+
18
+ import { getLogger } from "../util/logger.js";
19
+ import type { CredentialBackend, DeleteResult } from "./credential-backend.js";
20
+
21
+ const log = getLogger("ces-credential-client");
22
+
23
+ const REQUEST_TIMEOUT_MS = 10_000;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Env helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function getBaseUrl(): string | undefined {
30
+ return process.env.CES_CREDENTIAL_URL;
31
+ }
32
+
33
+ function getServiceToken(): string | undefined {
34
+ return process.env.CES_SERVICE_TOKEN;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Internal fetch wrapper
39
+ // ---------------------------------------------------------------------------
40
+
41
+ async function cesRequest(
42
+ method: string,
43
+ path: string,
44
+ body?: unknown,
45
+ ): Promise<Response | null> {
46
+ const baseUrl = getBaseUrl();
47
+ const token = getServiceToken();
48
+ if (!baseUrl || !token) return null;
49
+
50
+ const url = `${baseUrl.replace(/\/+$/, "")}${path}`;
51
+ const headers: Record<string, string> = {
52
+ Authorization: `Bearer ${token}`,
53
+ "Content-Type": "application/json",
54
+ };
55
+
56
+ try {
57
+ return await fetch(url, {
58
+ method,
59
+ headers,
60
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
61
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
62
+ });
63
+ } catch (err) {
64
+ log.warn({ err, method, path }, "CES credential request failed");
65
+ return null;
66
+ }
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // CesCredentialBackend
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export class CesCredentialBackend implements CredentialBackend {
74
+ readonly name = "ces-http";
75
+
76
+ isAvailable(): boolean {
77
+ return !!getBaseUrl() && !!getServiceToken();
78
+ }
79
+
80
+ async get(account: string): Promise<string | undefined> {
81
+ try {
82
+ const res = await cesRequest(
83
+ "GET",
84
+ `/v1/credentials/${encodeURIComponent(account)}`,
85
+ );
86
+ if (!res) return undefined;
87
+ if (res.status === 404) return undefined;
88
+ if (!res.ok) {
89
+ log.warn(
90
+ { account, status: res.status },
91
+ "CES credential get returned non-OK status",
92
+ );
93
+ return undefined;
94
+ }
95
+ const data = (await res.json()) as { value?: string };
96
+ return data.value;
97
+ } catch (err) {
98
+ log.warn({ err, account }, "CES credential get threw unexpectedly");
99
+ return undefined;
100
+ }
101
+ }
102
+
103
+ async set(account: string, value: string): Promise<boolean> {
104
+ try {
105
+ const res = await cesRequest(
106
+ "POST",
107
+ `/v1/credentials/${encodeURIComponent(account)}`,
108
+ { value },
109
+ );
110
+ if (!res) return false;
111
+ if (!res.ok) {
112
+ log.warn(
113
+ { account, status: res.status },
114
+ "CES credential set returned non-OK status",
115
+ );
116
+ return false;
117
+ }
118
+ return true;
119
+ } catch (err) {
120
+ log.warn({ err, account }, "CES credential set threw unexpectedly");
121
+ return false;
122
+ }
123
+ }
124
+
125
+ async delete(account: string): Promise<DeleteResult> {
126
+ try {
127
+ const res = await cesRequest(
128
+ "DELETE",
129
+ `/v1/credentials/${encodeURIComponent(account)}`,
130
+ );
131
+ if (!res) return "error";
132
+ if (res.status === 404) return "not-found";
133
+ if (!res.ok) {
134
+ log.warn(
135
+ { account, status: res.status },
136
+ "CES credential delete returned non-OK status",
137
+ );
138
+ return "error";
139
+ }
140
+ return "deleted";
141
+ } catch (err) {
142
+ log.warn({ err, account }, "CES credential delete threw unexpectedly");
143
+ return "error";
144
+ }
145
+ }
146
+
147
+ async list(): Promise<string[]> {
148
+ try {
149
+ const res = await cesRequest("GET", "/v1/credentials");
150
+ if (!res) return [];
151
+ if (!res.ok) {
152
+ log.warn(
153
+ { status: res.status },
154
+ "CES credential list returned non-OK status",
155
+ );
156
+ return [];
157
+ }
158
+ const data = (await res.json()) as { accounts?: string[] };
159
+ return data.accounts ?? [];
160
+ } catch (err) {
161
+ log.warn({ err }, "CES credential list threw unexpectedly");
162
+ return [];
163
+ }
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Factory
169
+ // ---------------------------------------------------------------------------
170
+
171
+ export function createCesCredentialBackend(): CesCredentialBackend {
172
+ return new CesCredentialBackend();
173
+ }
@@ -3,6 +3,7 @@
3
3
  * adapters.
4
4
  *
5
5
  * Backend selection (`resolveBackend`) is the single decision point:
6
+ * - Containerized (IS_CONTAINERIZED + CES_CREDENTIAL_URL set): CES HTTP client.
6
7
  * - Production (VELLUM_DEV unset or "0"): keychain backend when available.
7
8
  * - Dev mode (VELLUM_DEV=1): encrypted file store always.
8
9
  *
@@ -16,7 +17,10 @@ import type {
16
17
  SecureKeyDeleteResult,
17
18
  } from "@vellumai/credential-storage";
18
19
 
20
+ import providerEnvVarsRegistry from "../../../meta/provider-env-vars.json" with { type: "json" };
21
+ import { getIsContainerized } from "../config/env-registry.js";
19
22
  import { getLogger } from "../util/logger.js";
23
+ import { createCesCredentialBackend } from "./ces-credential-client.js";
20
24
  import type { CredentialBackend, DeleteResult } from "./credential-backend.js";
21
25
  import {
22
26
  createEncryptedStoreBackend,
@@ -50,18 +54,39 @@ function getEncryptedStoreBackend(): CredentialBackend {
50
54
 
51
55
  /**
52
56
  * Resolve the primary credential backend for this process.
53
- * Production (VELLUM_DEV unset or "0") uses keychain when available.
54
- * Dev mode (VELLUM_DEV=1) always uses the encrypted file store.
57
+ *
58
+ * Priority:
59
+ * 1. Containerized + CES_CREDENTIAL_URL → CES HTTP client (skip keychain
60
+ * and encrypted store entirely — the sidecar owns credential storage).
61
+ * 2. Production (VELLUM_DEV unset or "0") → keychain when available.
62
+ * 3. Dev mode (VELLUM_DEV=1) → encrypted file store always.
55
63
  *
56
64
  * Once resolved, the backend does not change during the process lifetime.
57
65
  * Call `_resetBackend()` in tests to clear the cached resolution.
58
66
  */
59
67
  function resolveBackend(): CredentialBackend {
60
68
  if (!_resolvedBackend) {
61
- if (process.env.VELLUM_DEV !== "1" && getKeychainBackend().isAvailable()) {
62
- _resolvedBackend = getKeychainBackend();
63
- } else {
64
- _resolvedBackend = getEncryptedStoreBackend();
69
+ if (getIsContainerized() && process.env.CES_CREDENTIAL_URL) {
70
+ const ces = createCesCredentialBackend();
71
+ if (ces.isAvailable()) {
72
+ _resolvedBackend = ces;
73
+ } else {
74
+ log.warn(
75
+ "CES_CREDENTIAL_URL is set but CES backend is not available — " +
76
+ "falling back to local credential store",
77
+ );
78
+ }
79
+ }
80
+
81
+ if (!_resolvedBackend) {
82
+ if (
83
+ process.env.VELLUM_DEV !== "1" &&
84
+ getKeychainBackend().isAvailable()
85
+ ) {
86
+ _resolvedBackend = getKeychainBackend();
87
+ } else {
88
+ _resolvedBackend = getEncryptedStoreBackend();
89
+ }
65
90
  }
66
91
  }
67
92
  return _resolvedBackend;
@@ -70,6 +95,8 @@ function resolveBackend(): CredentialBackend {
70
95
  /**
71
96
  * List all account names across both backends (async).
72
97
  *
98
+ * In CES mode, only the CES backend is queried — there are no local stores.
99
+ *
73
100
  * When the primary backend is the keychain, this merges keys from the keychain
74
101
  * and the encrypted store (for legacy keys that haven't been migrated). The
75
102
  * result is deduplicated. When the primary backend is already the encrypted
@@ -79,6 +106,9 @@ export async function listSecureKeysAsync(): Promise<string[]> {
79
106
  const backend = resolveBackend();
80
107
  const primaryKeys = await backend.list();
81
108
 
109
+ // CES mode — the sidecar is the single source of truth, no local merge.
110
+ if (backend.name === "ces-http") return primaryKeys;
111
+
82
112
  // If primary backend is NOT the encrypted store, also check
83
113
  // the encrypted store for legacy keys that haven't been migrated.
84
114
  if (backend !== getEncryptedStoreBackend()) {
@@ -96,8 +126,10 @@ export async function listSecureKeysAsync(): Promise<string[]> {
96
126
 
97
127
  /**
98
128
  * Retrieve a secret from secure storage. Reads from the primary backend
99
- * first. If the primary backend is the keychain, falls back to the encrypted
100
- * store for legacy keys that haven't been migrated.
129
+ * first. In CES mode, the sidecar is the single source of truth no
130
+ * local fallback. In local mode, if the primary backend is the keychain,
131
+ * falls back to the encrypted store for legacy keys that haven't been
132
+ * migrated.
101
133
  */
102
134
  export async function getSecureKeyAsync(
103
135
  account: string,
@@ -106,6 +138,9 @@ export async function getSecureKeyAsync(
106
138
  const result = await backend.get(account);
107
139
  if (result != null) return result;
108
140
 
141
+ // CES mode — no local fallback.
142
+ if (backend.name === "ces-http") return undefined;
143
+
109
144
  // Legacy fallback: if primary backend is NOT the encrypted store,
110
145
  // check the encrypted store for keys that haven't been migrated.
111
146
  if (backend !== getEncryptedStoreBackend()) {
@@ -135,13 +170,25 @@ export async function setSecureKeyAsync(
135
170
  }
136
171
 
137
172
  /**
138
- * Delete a secret from secure storage. Always attempts deletion on both
139
- * the keychain backend (if available) and the encrypted store backend,
140
- * regardless of routing mode. This cleans up legacy data from both stores.
173
+ * Delete a secret from secure storage.
174
+ *
175
+ * In containerized mode with CES, deletion is routed exclusively through the
176
+ * CES backend — there are no local stores to clean up.
177
+ *
178
+ * In local mode, always attempts deletion on both the keychain backend (if
179
+ * available) and the encrypted store backend, regardless of routing mode.
180
+ * This cleans up legacy data from both stores.
141
181
  */
142
182
  export async function deleteSecureKeyAsync(
143
183
  account: string,
144
184
  ): Promise<DeleteResult> {
185
+ const backend = resolveBackend();
186
+
187
+ // In CES mode, the sidecar is the only store — no local cleanup needed.
188
+ if (backend.name === "ces-http") {
189
+ return backend.delete(account);
190
+ }
191
+
145
192
  const keychain = getKeychainBackend();
146
193
  const enc = getEncryptedStoreBackend();
147
194
 
@@ -164,18 +211,14 @@ export async function deleteSecureKeyAsync(
164
211
  // ---------------------------------------------------------------------------
165
212
 
166
213
  /**
167
- * Env var names keyed by provider. The convention is `<PROVIDER>_API_KEY`.
168
- * Ollama is intentionally omitted it doesn't require an API key.
214
+ * Env var names keyed by provider. Loaded from the shared registry at
215
+ * `meta/provider-env-vars.json` the single source of truth also consumed
216
+ * by the CLI and the macOS client.
217
+ * Ollama is intentionally omitted from the registry — it doesn't require
218
+ * an API key.
169
219
  */
170
- const PROVIDER_ENV_VARS: Record<string, string> = {
171
- anthropic: "ANTHROPIC_API_KEY",
172
- openai: "OPENAI_API_KEY",
173
- gemini: "GEMINI_API_KEY",
174
- fireworks: "FIREWORKS_API_KEY",
175
- openrouter: "OPENROUTER_API_KEY",
176
- brave: "BRAVE_API_KEY",
177
- perplexity: "PERPLEXITY_API_KEY",
178
- };
220
+ const PROVIDER_ENV_VARS: Record<string, string> =
221
+ providerEnvVarsRegistry.providers;
179
222
 
180
223
  /**
181
224
  * Retrieve a provider API key, checking secure storage first and falling
@@ -20,6 +20,7 @@ import { spawn } from "node:child_process";
20
20
  import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
21
21
  import { join } from "node:path";
22
22
 
23
+ import { getIsContainerized } from "../config/env-registry.js";
23
24
  import { getLogger } from "../util/logger.js";
24
25
  import { getSignalsDir, getWorkspaceDir } from "../util/platform.js";
25
26
 
@@ -65,6 +66,8 @@ function writeResult(requestId: string, result: BashSignalResult): void {
65
66
  * when a matching signal file is created or modified.
66
67
  */
67
68
  export function handleBashSignal(filename: string): void {
69
+ if (getIsContainerized()) return;
70
+
68
71
  if (!isDebugMode()) {
69
72
  log.warn(
70
73
  { filename },
@@ -13,6 +13,7 @@
13
13
  import { readFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
 
16
+ import { getIsContainerized } from "../config/env-registry.js";
16
17
  import { getLogger } from "../util/logger.js";
17
18
  import { getSignalsDir } from "../util/platform.js";
18
19
 
@@ -39,6 +40,8 @@ export function registerCancelCallback(cb: CancelCallback): void {
39
40
  * Called by ConfigWatcher when the signal file is written or modified.
40
41
  */
41
42
  export function handleCancelSignal(): void {
43
+ if (getIsContainerized()) return;
44
+
42
45
  try {
43
46
  const content = readFileSync(join(getSignalsDir(), "cancel"), "utf-8");
44
47
  const parsed = JSON.parse(content) as { conversationId?: string };
@@ -10,6 +10,7 @@
10
10
  import { readFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
+ import { getIsContainerized } from "../config/env-registry.js";
13
14
  import type { UserDecision } from "../permissions/types.js";
14
15
  import * as pendingInteractions from "../runtime/pending-interactions.js";
15
16
  import { getLogger } from "../util/logger.js";
@@ -37,6 +38,8 @@ function isUserDecision(value: string): value is UserDecision {
37
38
  * Called by ConfigWatcher when the signal file is written or modified.
38
39
  */
39
40
  export function handleConfirmationSignal(): void {
41
+ if (getIsContainerized()) return;
42
+
40
43
  try {
41
44
  const content = readFileSync(join(getSignalsDir(), "confirm"), "utf-8");
42
45
  const parsed = JSON.parse(content) as {
@@ -16,6 +16,7 @@
16
16
  import { readFileSync, writeFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
 
19
+ import { getIsContainerized } from "../config/env-registry.js";
19
20
  import { getLogger } from "../util/logger.js";
20
21
  import { getSignalsDir } from "../util/platform.js";
21
22
 
@@ -46,6 +47,8 @@ export function registerConversationUndoCallback(cb: UndoCallback): void {
46
47
  * file is written.
47
48
  */
48
49
  export async function handleConversationUndoSignal(): Promise<void> {
50
+ if (getIsContainerized()) return;
51
+
49
52
  const resultPath = join(getSignalsDir(), "conversation-undo.result");
50
53
 
51
54
  const writeResult = (
@@ -24,6 +24,7 @@ import {
24
24
  } from "node:fs";
25
25
  import { join } from "node:path";
26
26
 
27
+ import { getIsContainerized } from "../config/env-registry.js";
27
28
  import type { AssistantEvent } from "../runtime/assistant-event.js";
28
29
  import { getSignalsDir } from "../util/platform.js";
29
30
 
@@ -86,6 +87,8 @@ export function appendEventToStream(
86
87
  conversationId: string,
87
88
  event: AssistantEvent,
88
89
  ): void {
90
+ if (getIsContainerized()) return;
91
+
89
92
  const dirs = getSubscriberDirs(conversationId);
90
93
  if (dirs.length === 0) return;
91
94
 
@@ -130,6 +133,10 @@ export function watchEventStream(
130
133
  conversationId: string,
131
134
  callback: (event: AssistantEvent) => void,
132
135
  ): EventStreamWatcher {
136
+ if (getIsContainerized()) {
137
+ return { dispose() {} };
138
+ }
139
+
133
140
  const parentDir = eventsDir();
134
141
  mkdirSync(parentDir, { recursive: true });
135
142
  const subDir = join(parentDir, `${conversationId}.${process.pid}`);
@@ -15,6 +15,7 @@ import crypto from "node:crypto";
15
15
  import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
 
18
+ import { getIsContainerized } from "../config/env-registry.js";
18
19
  import {
19
20
  fireWatchCompletionNotifier,
20
21
  fireWatchStartNotifier,
@@ -54,6 +55,8 @@ function writeResult(requestId: string, result: ShotgunResult): void {
54
55
  * when a matching signal file is created or modified.
55
56
  */
56
57
  export function handleShotgunSignal(filename: string): void {
58
+ if (getIsContainerized()) return;
59
+
57
60
  const signalPath = join(getSignalsDir(), filename);
58
61
  let raw: string;
59
62
  try {
@@ -12,6 +12,7 @@
12
12
  import { readFileSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
 
15
+ import { getIsContainerized } from "../config/env-registry.js";
15
16
  import { addRule } from "../permissions/trust-store.js";
16
17
  import * as pendingInteractions from "../runtime/pending-interactions.js";
17
18
  import { getTool } from "../tools/registry.js";
@@ -27,6 +28,8 @@ const VALID_TRUST_DECISIONS: ReadonlySet<string> = new Set(["allow", "deny"]);
27
28
  * Called by ConfigWatcher when the signal file is written or modified.
28
29
  */
29
30
  export function handleTrustRuleSignal(): void {
31
+ if (getIsContainerized()) return;
32
+
30
33
  const resultPath = join(getSignalsDir(), "trust-rule.result");
31
34
 
32
35
  const writeError = (requestId: string | undefined, error: string): void => {
@@ -41,14 +41,12 @@ mock.module("../memory/turn-events-store.js", () => ({
41
41
  queryUnreportedTurnEvents: mockQueryUnreportedTurnEvents,
42
42
  }));
43
43
 
44
- const mockResolveManagedProxyContext = mock(async () => ({
45
- enabled: false,
46
- platformBaseUrl: "",
47
- assistantApiKey: "",
48
- }));
44
+ let mockPlatformClient: Record<string, unknown> | null = null;
49
45
 
50
- mock.module("../providers/managed-proxy/context.js", () => ({
51
- resolveManagedProxyContext: mockResolveManagedProxyContext,
46
+ mock.module("../platform/client.js", () => ({
47
+ VellumPlatformClient: {
48
+ create: async () => mockPlatformClient,
49
+ },
52
50
  }));
53
51
 
54
52
  const mockGetTelemetryPlatformUrl = mock(() => "https://platform.vellum.ai");
@@ -142,7 +140,7 @@ beforeEach(() => {
142
140
  mockQueryUnreportedUsageEvents.mockReset();
143
141
  mockQueryUnreportedTurnEvents.mockReset();
144
142
  mockQueryUnreportedTurnEvents.mockReturnValue([]);
145
- mockResolveManagedProxyContext.mockReset();
143
+ mockPlatformClient = null;
146
144
  mockGetTelemetryPlatformUrl.mockReset();
147
145
  mockGetTelemetryAppToken.mockReset();
148
146
  mockGetDeviceId.mockReset();
@@ -156,11 +154,6 @@ beforeEach(() => {
156
154
 
157
155
  // Defaults
158
156
  mockGetMemoryCheckpoint.mockReturnValue(null);
159
- mockResolveManagedProxyContext.mockResolvedValue({
160
- enabled: false,
161
- platformBaseUrl: "",
162
- assistantApiKey: "",
163
- });
164
157
  mockGetTelemetryPlatformUrl.mockReturnValue("https://platform.vellum.ai");
165
158
  mockGetTelemetryAppToken.mockReturnValue("default-test-token");
166
159
 
@@ -179,37 +172,31 @@ afterEach(() => {
179
172
  // ---------------------------------------------------------------------------
180
173
 
181
174
  describe("UsageTelemetryReporter", () => {
182
- test("authenticated flush uses Api-Key header and proxy URL", async () => {
183
- mockResolveManagedProxyContext.mockResolvedValue({
184
- enabled: true,
185
- platformBaseUrl: "https://test.vellum.ai",
186
- assistantApiKey: "test-key",
175
+ test("authenticated flush uses client.fetch with platform path", async () => {
176
+ const clientFetchMock = mock(async (_path: string, _init?: RequestInit) => {
177
+ return new Response('{"accepted":2}', { status: 200 });
187
178
  });
179
+ mockPlatformClient = {
180
+ baseUrl: "https://test.vellum.ai",
181
+ assistantApiKey: "test-key",
182
+ platformAssistantId: "asst-123",
183
+ fetch: clientFetchMock,
184
+ };
188
185
  const events = [makeUsageEvent(), makeUsageEvent()];
189
186
  mockQueryUnreportedUsageEvents.mockReturnValue(events);
190
- mockFetch.mockImplementation(() =>
191
- Promise.resolve(
192
- new Response(`{"accepted":${events.length}}`, { status: 200 }),
193
- ),
194
- );
195
187
 
196
188
  const reporter = new UsageTelemetryReporter();
197
189
  await reporter.flush();
198
190
 
199
- expect(mockFetch).toHaveBeenCalledTimes(1);
200
- const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit];
201
- expect(url).toBe("https://test.vellum.ai/v1/telemetry/ingest/");
202
- expect((opts.headers as Record<string, string>)["Authorization"]).toBe(
203
- "Api-Key test-key",
204
- );
191
+ expect(clientFetchMock).toHaveBeenCalledTimes(1);
192
+ const [path] = clientFetchMock.mock.calls[0] as [string, RequestInit];
193
+ expect(path).toBe("/v1/telemetry/ingest/");
194
+ // globalThis.fetch should NOT have been called directly
195
+ expect(mockFetch).not.toHaveBeenCalled();
205
196
  });
206
197
 
207
198
  test("anonymous flush uses X-Telemetry-Token and default URL", async () => {
208
- mockResolveManagedProxyContext.mockResolvedValue({
209
- enabled: false,
210
- platformBaseUrl: "",
211
- assistantApiKey: "",
212
- });
199
+ mockPlatformClient = null;
213
200
  mockGetTelemetryPlatformUrl.mockReturnValue("https://platform.test.ai");
214
201
  mockGetTelemetryAppToken.mockReturnValue("anon-token");
215
202
 
@@ -375,9 +362,9 @@ describe("UsageTelemetryReporter", () => {
375
362
  (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string,
376
363
  );
377
364
 
378
- // Top-level: installation_id, app_version, and events array (no turn_events key)
365
+ // Top-level: installation_id, assistant_version, and events array (no turn_events key)
379
366
  expect(body.installation_id).toBe("test-device-id");
380
- expect(body.app_version).toBe("1.2.3-test");
367
+ expect(body.assistant_version).toBe("1.2.3-test");
381
368
  expect(Array.isArray(body.events)).toBe(true);
382
369
  expect(body.events.length).toBe(1);
383
370
  expect(body.turn_events).toBeUndefined();
@@ -22,7 +22,7 @@ import {
22
22
  import { queryUnreportedLifecycleEvents } from "../memory/lifecycle-events-store.js";
23
23
  import { queryUnreportedUsageEvents } from "../memory/llm-usage-store.js";
24
24
  import { queryUnreportedTurnEvents } from "../memory/turn-events-store.js";
25
- import { resolveManagedProxyContext } from "../providers/managed-proxy/context.js";
25
+ import { VellumPlatformClient } from "../platform/client.js";
26
26
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
27
27
  import { getExternalAssistantId } from "../runtime/auth/external-assistant-id.js";
28
28
  import { getDeviceId } from "../util/device-id.js";
@@ -139,22 +139,11 @@ export class UsageTelemetryReporter {
139
139
  return;
140
140
 
141
141
  // Resolve auth context — skip flush when neither auth mode is viable
142
- const proxyCtx = await resolveManagedProxyContext();
143
- if (!proxyCtx.enabled && !getTelemetryAppToken()) {
142
+ const client = await VellumPlatformClient.create();
143
+ if (!client && !getTelemetryAppToken()) {
144
144
  return;
145
145
  }
146
146
 
147
- let url: string;
148
- let authHeaders: Record<string, string>;
149
-
150
- if (proxyCtx.enabled) {
151
- url = `${proxyCtx.platformBaseUrl}${TELEMETRY_PATH}`;
152
- authHeaders = { Authorization: `Api-Key ${proxyCtx.assistantApiKey}` };
153
- } else {
154
- url = `${getTelemetryPlatformUrl()}${TELEMETRY_PATH}`;
155
- authHeaders = { "X-Telemetry-Token": getTelemetryAppToken() };
156
- }
157
-
158
147
  // Build payload
159
148
  const typedEvents: TelemetryEvent[] = [
160
149
  ...events.map(
@@ -193,28 +182,41 @@ export class UsageTelemetryReporter {
193
182
  const organizationId = getPlatformOrganizationId() || undefined;
194
183
  const userId = getPlatformUserId() || undefined;
195
184
  const payload = {
196
- installation_id: getDeviceId(),
185
+ device_id: getDeviceId(),
197
186
  assistant_id: assistantId,
198
- app_version: APP_VERSION,
187
+ assistant_version: APP_VERSION,
199
188
  ...(organizationId ? { organization_id: organizationId } : {}),
200
189
  ...(userId ? { user_id: userId } : {}),
201
190
  events: typedEvents,
202
191
  };
203
192
 
204
193
  // Send
205
- const resp = await fetch(url, {
194
+ const fetchInit: RequestInit = {
206
195
  method: "POST",
207
196
  headers: {
208
197
  "Content-Type": "application/json",
209
- ...authHeaders,
210
198
  },
211
199
  body: JSON.stringify(payload),
212
- });
200
+ };
201
+
202
+ let resp: Response;
203
+ if (client) {
204
+ resp = await client.fetch(TELEMETRY_PATH, fetchInit);
205
+ } else {
206
+ const url = `${getTelemetryPlatformUrl()}${TELEMETRY_PATH}`;
207
+ resp = await fetch(url, {
208
+ ...fetchInit,
209
+ headers: {
210
+ "Content-Type": "application/json",
211
+ "X-Telemetry-Token": getTelemetryAppToken(),
212
+ },
213
+ });
214
+ }
213
215
 
214
216
  if (!resp.ok) {
215
217
  await resp.text(); // consume body to release connection
216
218
  log.warn(
217
- { status: resp.status, url },
219
+ { status: resp.status },
218
220
  "Usage telemetry POST failed — will retry next cycle",
219
221
  );
220
222
  return;
@@ -38,8 +38,13 @@ class FileEditTool implements Tool {
38
38
  description:
39
39
  "Replace all occurrences of old_string instead of requiring a unique match (default: false)",
40
40
  },
41
+ activity: {
42
+ type: "string",
43
+ description:
44
+ "Brief non-technical explanation of what you are doing and why, shown to the user as a status update.",
45
+ },
41
46
  },
42
- required: ["path", "old_string", "new_string"],
47
+ required: ["path", "old_string", "new_string", "activity"],
43
48
  },
44
49
  };
45
50
  }