@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,319 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { OAuthConnection } from "../oauth/connection.js";
4
+
5
+ // ── Mocks ───────────────────────────────────────────────────────────────────
6
+
7
+ const getSecureKeyAsyncMock = mock(
8
+ async (_key: string): Promise<string | null> => null,
9
+ );
10
+ const isProviderConnectedMock = mock(
11
+ async (_service: string): Promise<boolean> => false,
12
+ );
13
+ const resolveOAuthConnectionMock = mock(
14
+ async (
15
+ _service: string,
16
+ _opts?: { account?: string },
17
+ ): Promise<OAuthConnection> =>
18
+ ({ accessToken: "oauth-token" }) as unknown as OAuthConnection,
19
+ );
20
+ const getConnectionByProviderMock = mock(
21
+ (_provider: string): { status: string } | undefined => undefined,
22
+ );
23
+
24
+ mock.module("../security/secure-keys.js", () => ({
25
+ getSecureKeyAsync: getSecureKeyAsyncMock,
26
+ }));
27
+
28
+ mock.module("../oauth/oauth-store.js", () => ({
29
+ isProviderConnected: isProviderConnectedMock,
30
+ getConnectionByProvider: getConnectionByProviderMock,
31
+ }));
32
+
33
+ mock.module("../oauth/connection-resolver.js", () => ({
34
+ resolveOAuthConnection: resolveOAuthConnectionMock,
35
+ }));
36
+
37
+ // Telegram adapter imports modules that need more stubs
38
+ mock.module("../config/env.js", () => ({
39
+ getGatewayInternalBaseUrl: () => "http://localhost:3000",
40
+ }));
41
+ mock.module("../memory/conversation-key-store.js", () => ({
42
+ getOrCreateConversation: async () => "conv-1",
43
+ }));
44
+ mock.module("../memory/external-conversation-store.js", () => ({
45
+ getExternalConversation: () => undefined,
46
+ setExternalConversation: () => {},
47
+ }));
48
+ mock.module("../runtime/auth/token-service.js", () => ({
49
+ mintDaemonDeliveryToken: async () => "delivery-token",
50
+ }));
51
+
52
+ // Slack client stubs (not exercised in these tests, but required on import)
53
+ mock.module("../messaging/providers/slack/client.js", () => ({}));
54
+
55
+ // Gmail client stubs
56
+ mock.module("../messaging/providers/gmail/client.js", () => ({}));
57
+ mock.module("../messaging/providers/gmail/people-client.js", () => ({}));
58
+
59
+ // Telegram client stubs
60
+ mock.module("../messaging/providers/telegram-bot/client.js", () => ({}));
61
+
62
+ import {
63
+ getProviderConnection,
64
+ resolveProvider,
65
+ } from "../config/bundled-skills/messaging/tools/shared.js";
66
+ import { gmailMessagingProvider } from "../messaging/providers/gmail/adapter.js";
67
+ import { slackProvider } from "../messaging/providers/slack/adapter.js";
68
+ import { telegramBotMessagingProvider } from "../messaging/providers/telegram-bot/adapter.js";
69
+ import {
70
+ getConnectedProviders,
71
+ registerMessagingProvider,
72
+ } from "../messaging/registry.js";
73
+
74
+ // Register providers for integration tests
75
+ registerMessagingProvider(slackProvider);
76
+ registerMessagingProvider(gmailMessagingProvider);
77
+ registerMessagingProvider(telegramBotMessagingProvider);
78
+
79
+ // ── Helpers ─────────────────────────────────────────────────────────────────
80
+
81
+ function resetAllMocks() {
82
+ getSecureKeyAsyncMock.mockReset();
83
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
84
+ isProviderConnectedMock.mockReset();
85
+ isProviderConnectedMock.mockImplementation(async () => false);
86
+ resolveOAuthConnectionMock.mockReset();
87
+ resolveOAuthConnectionMock.mockImplementation(
88
+ async () => ({ accessToken: "oauth-token" }) as unknown as OAuthConnection,
89
+ );
90
+ getConnectionByProviderMock.mockReset();
91
+ getConnectionByProviderMock.mockImplementation(() => undefined);
92
+ }
93
+
94
+ // ── Tests ───────────────────────────────────────────────────────────────────
95
+
96
+ describe("Slack messaging token resolution", () => {
97
+ beforeEach(resetAllMocks);
98
+
99
+ // ── slackProvider.isConnected() ─────────────────────────────────────────
100
+
101
+ describe("slackProvider.isConnected()", () => {
102
+ test("returns true when slack_channel bot token exists in credential store", async () => {
103
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
104
+ key === "credential/slack_channel/bot_token" ? "xoxb-bot-token" : null,
105
+ );
106
+
107
+ expect(await slackProvider.isConnected!()).toBe(true);
108
+ });
109
+
110
+ test("returns true even if slack_channel connection row is missing (backfill failure resilience)", async () => {
111
+ // Bot token exists but no connection row — isConnected should still return true
112
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
113
+ key === "credential/slack_channel/bot_token" ? "xoxb-bot-token" : null,
114
+ );
115
+ // No getConnectionByProvider call expected — Slack adapter checks token first
116
+
117
+ expect(await slackProvider.isConnected!()).toBe(true);
118
+ });
119
+
120
+ test("returns true when only integration:slack has active OAuth connection (backwards compat)", async () => {
121
+ // No bot token
122
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
123
+ // But OAuth provider is connected
124
+ isProviderConnectedMock.mockImplementation(async (service: string) =>
125
+ service === "integration:slack" ? true : false,
126
+ );
127
+
128
+ expect(await slackProvider.isConnected!()).toBe(true);
129
+ });
130
+
131
+ test("returns false when neither credential path exists", async () => {
132
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
133
+ isProviderConnectedMock.mockImplementation(async () => false);
134
+
135
+ expect(await slackProvider.isConnected!()).toBe(false);
136
+ });
137
+ });
138
+
139
+ // ── slackProvider.resolveConnection() ───────────────────────────────────
140
+
141
+ describe("slackProvider.resolveConnection()", () => {
142
+ test("returns bot token string when Socket Mode credentials exist", async () => {
143
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
144
+ key === "credential/slack_channel/bot_token"
145
+ ? "xoxb-socket-token"
146
+ : null,
147
+ );
148
+
149
+ const result = await slackProvider.resolveConnection!();
150
+ expect(result).toBe("xoxb-socket-token");
151
+ });
152
+
153
+ test("returns bot token string even without a slack_channel connection row (token-only resilience)", async () => {
154
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
155
+ key === "credential/slack_channel/bot_token" ? "xoxb-token-only" : null,
156
+ );
157
+ // No connection row — resolveConnection should still return the token
158
+
159
+ const result = await slackProvider.resolveConnection!();
160
+ expect(result).toBe("xoxb-token-only");
161
+ });
162
+
163
+ test("returns OAuthConnection when only OAuth integration:slack credentials exist (backwards compat)", async () => {
164
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
165
+ const oauthConn = {
166
+ accessToken: "xoxp-oauth-token",
167
+ } as unknown as OAuthConnection;
168
+ resolveOAuthConnectionMock.mockImplementation(async () => oauthConn);
169
+
170
+ const result = await slackProvider.resolveConnection!();
171
+ expect(result).toBe(oauthConn);
172
+ expect(resolveOAuthConnectionMock).toHaveBeenCalledWith(
173
+ "integration:slack",
174
+ { account: undefined },
175
+ );
176
+ });
177
+
178
+ test("throws when no credentials exist at all (no Socket Mode, no OAuth)", async () => {
179
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
180
+ resolveOAuthConnectionMock.mockImplementation(async () => {
181
+ throw new Error("No OAuth connection found for integration:slack");
182
+ });
183
+
184
+ await expect(slackProvider.resolveConnection!()).rejects.toThrow(
185
+ "No OAuth connection found",
186
+ );
187
+ });
188
+ });
189
+
190
+ // ── getProviderConnection() integration ─────────────────────────────────
191
+
192
+ describe("getProviderConnection()", () => {
193
+ test("returns bot token string for Slack when Socket Mode credentials exist", async () => {
194
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
195
+ key === "credential/slack_channel/bot_token" ? "xoxb-conn-token" : null,
196
+ );
197
+
198
+ const result = await getProviderConnection(slackProvider);
199
+ expect(result).toBe("xoxb-conn-token");
200
+ });
201
+
202
+ test("returns OAuthConnection for Slack when only OAuth credentials exist (backwards compat)", async () => {
203
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
204
+ const oauthConn = {
205
+ accessToken: "xoxp-oauth-token",
206
+ } as unknown as OAuthConnection;
207
+ resolveOAuthConnectionMock.mockImplementation(async () => oauthConn);
208
+
209
+ const result = await getProviderConnection(slackProvider);
210
+ expect(result).toBe(oauthConn);
211
+ });
212
+
213
+ test("throws when no Slack credentials exist", async () => {
214
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
215
+ resolveOAuthConnectionMock.mockImplementation(async () => {
216
+ throw new Error("No OAuth connection found");
217
+ });
218
+
219
+ await expect(getProviderConnection(slackProvider)).rejects.toThrow(
220
+ "No OAuth connection found",
221
+ );
222
+ });
223
+
224
+ test('Telegram still returns "" (no resolveConnection, uses isConnected path — regression check)', async () => {
225
+ // Telegram has isConnected but no resolveConnection.
226
+ // When isConnected returns true, getProviderConnection returns ""
227
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) => {
228
+ if (key === "credential/telegram/bot_token") return "bot-token";
229
+ if (key === "credential/telegram/webhook_secret") return "secret";
230
+ return null;
231
+ });
232
+ getConnectionByProviderMock.mockImplementation((provider: string) =>
233
+ provider === "telegram" ? { status: "active" } : undefined,
234
+ );
235
+
236
+ const result = await getProviderConnection(telegramBotMessagingProvider);
237
+ expect(result).toBe("");
238
+ });
239
+
240
+ test("Gmail still calls resolveOAuthConnection (no resolveConnection, no isConnected — regression check)", async () => {
241
+ // Gmail has neither resolveConnection nor isConnected.
242
+ // getProviderConnection falls through to resolveOAuthConnection.
243
+ const oauthConn = {
244
+ accessToken: "gmail-oauth-token",
245
+ } as unknown as OAuthConnection;
246
+ resolveOAuthConnectionMock.mockImplementation(async () => oauthConn);
247
+
248
+ const result = await getProviderConnection(gmailMessagingProvider);
249
+ expect(result).toBe(oauthConn);
250
+ expect(resolveOAuthConnectionMock).toHaveBeenCalledWith(
251
+ "integration:google",
252
+ { account: undefined },
253
+ );
254
+ });
255
+ });
256
+
257
+ // ── resolveProvider() multi-platform behavior ───────────────────────────
258
+
259
+ describe("resolveProvider() multi-platform behavior", () => {
260
+ test('throws "Multiple platforms connected" when both Gmail and Slack (Socket Mode) are connected and no platform is specified', async () => {
261
+ // Slack connected via Socket Mode
262
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
263
+ key === "credential/slack_channel/bot_token" ? "xoxb-token" : null,
264
+ );
265
+ // Gmail connected via OAuth
266
+ isProviderConnectedMock.mockImplementation(async (service: string) =>
267
+ service === "integration:google" ? true : false,
268
+ );
269
+
270
+ await expect(resolveProvider()).rejects.toThrow(
271
+ "Multiple platforms connected",
272
+ );
273
+ });
274
+
275
+ test("auto-selects Slack when it is the only connected provider", async () => {
276
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
277
+ key === "credential/slack_channel/bot_token" ? "xoxb-only" : null,
278
+ );
279
+ isProviderConnectedMock.mockImplementation(async () => false);
280
+
281
+ const provider = await resolveProvider();
282
+ expect(provider.id).toBe("slack");
283
+ });
284
+
285
+ test("auto-selects Gmail when it is the only connected provider (no Slack credentials)", async () => {
286
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
287
+ isProviderConnectedMock.mockImplementation(async (service: string) =>
288
+ service === "integration:google" ? true : false,
289
+ );
290
+
291
+ const provider = await resolveProvider();
292
+ expect(provider.id).toBe("gmail");
293
+ });
294
+ });
295
+
296
+ // ── getConnectedProviders() ─────────────────────────────────────────────
297
+
298
+ describe("getConnectedProviders()", () => {
299
+ test("includes Slack when connected via Socket Mode (slack_channel)", async () => {
300
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
301
+ key === "credential/slack_channel/bot_token" ? "xoxb-token" : null,
302
+ );
303
+ isProviderConnectedMock.mockImplementation(async () => false);
304
+
305
+ const connected = await getConnectedProviders();
306
+ const ids = connected.map((p) => p.id);
307
+ expect(ids).toContain("slack");
308
+ });
309
+
310
+ test("excludes Slack when no bot token exists", async () => {
311
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
312
+ isProviderConnectedMock.mockImplementation(async () => false);
313
+
314
+ const connected = await getConnectedProviders();
315
+ const ids = connected.map((p) => p.id);
316
+ expect(ids).not.toContain("slack");
317
+ });
318
+ });
319
+ });
@@ -0,0 +1,155 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import { describe, expect, test } from "bun:test";
4
+
5
+ /**
6
+ * Guard test: assistant source code must not directly access files in the
7
+ * `protected/` directory (`trust.json`, `keys.enc`, `store.key`,
8
+ * `actor-token-signing-key`). In containerized (Docker) mode these files
9
+ * live outside the assistant's data volume and are managed by the gateway.
10
+ *
11
+ * All access must go through the appropriate abstraction layer:
12
+ * - Trust rules: trust-store.ts / trust-client.ts (file vs gateway backend)
13
+ * - Credentials: encrypted-store.ts / ces-credential-client.ts
14
+ * - Signing keys: secure-keys.ts / credential-backend.ts
15
+ *
16
+ * Only the abstraction-layer files themselves (and tests) are allowed to
17
+ * reference the raw file paths / helper functions.
18
+ */
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Allowed files — abstraction layers that legitimately access protected/ files
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const ALLOWED_FILES = new Set([
25
+ // Trust store backends
26
+ "assistant/src/permissions/trust-store.ts",
27
+ "assistant/src/permissions/trust-client.ts",
28
+ "assistant/src/permissions/trust-store-interface.ts",
29
+ // Credential / encrypted store backends
30
+ "assistant/src/security/encrypted-store.ts",
31
+ "assistant/src/security/secure-keys.ts",
32
+ "assistant/src/security/credential-backend.ts",
33
+ "assistant/src/security/ces-credential-client.ts",
34
+ // Token service owns the signing key lifecycle
35
+ "assistant/src/runtime/auth/token-service.ts",
36
+ // CLI commands that run outside Docker (doctor diagnostics, trust management)
37
+ "assistant/src/cli/commands/doctor.ts",
38
+ "assistant/src/cli/commands/trust.ts",
39
+ // Auth middleware documentation comment (not a file access)
40
+ "assistant/src/runtime/auth/middleware.ts",
41
+ ]);
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Patterns that indicate direct access to protected directory files
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Each entry is a `git grep -E` pattern and a human-readable description
49
+ * for the error message.
50
+ */
51
+ const GUARDED_PATTERNS: Array<{ pattern: string; description: string }> = [
52
+ {
53
+ pattern: "protected/trust\\.json",
54
+ description: "direct reference to protected/trust.json",
55
+ },
56
+ {
57
+ pattern: "protected/keys\\.enc",
58
+ description: "direct reference to protected/keys.enc",
59
+ },
60
+ {
61
+ pattern: "protected/store\\.key",
62
+ description: "direct reference to protected/store.key",
63
+ },
64
+ {
65
+ pattern: "actor-token-signing-key",
66
+ description: "direct reference to actor-token-signing-key file",
67
+ },
68
+ {
69
+ pattern: "\\bgetTrustPath\\b",
70
+ description: "use of getTrustPath() (trust-store internal)",
71
+ },
72
+ {
73
+ pattern: "\\bgetStoreKeyPath\\b",
74
+ description: "use of getStoreKeyPath() (encrypted-store internal)",
75
+ },
76
+ ];
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Helpers
80
+ // ---------------------------------------------------------------------------
81
+
82
+ function getRepoRoot(): string {
83
+ return join(process.cwd(), "..");
84
+ }
85
+
86
+ function isTestFile(filePath: string): boolean {
87
+ return (
88
+ filePath.includes("/__tests__/") ||
89
+ filePath.endsWith(".test.ts") ||
90
+ filePath.endsWith(".test.js") ||
91
+ filePath.endsWith(".spec.ts") ||
92
+ filePath.endsWith(".spec.js")
93
+ );
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Tests
98
+ // ---------------------------------------------------------------------------
99
+
100
+ describe("volume security: protected directory access guard", () => {
101
+ for (const { pattern, description } of GUARDED_PATTERNS) {
102
+ test(`no ${description} outside allowed files`, () => {
103
+ const repoRoot = getRepoRoot();
104
+
105
+ let grepOutput = "";
106
+ try {
107
+ grepOutput = execFileSync(
108
+ "git",
109
+ [
110
+ "grep",
111
+ "-lE",
112
+ pattern,
113
+ "--",
114
+ "assistant/src/**/*.ts",
115
+ "assistant/src/*.ts",
116
+ ],
117
+ { encoding: "utf-8", cwd: repoRoot },
118
+ ).trim();
119
+ } catch (err) {
120
+ // Exit code 1 means no matches — happy path
121
+ if ((err as { status?: number }).status === 1) {
122
+ return;
123
+ }
124
+ throw err;
125
+ }
126
+
127
+ const files = grepOutput.split("\n").filter((f) => f.length > 0);
128
+ const violations = files.filter(
129
+ (f) => !isTestFile(f) && !ALLOWED_FILES.has(f),
130
+ );
131
+
132
+ if (violations.length > 0) {
133
+ const message = [
134
+ `Found assistant source files with ${description}.`,
135
+ "",
136
+ "In containerized (Docker) mode, the protected/ directory is not",
137
+ "accessible to the assistant. All access to protected files must go",
138
+ "through the abstraction layers:",
139
+ " - Trust rules: trust-store.ts / trust-client.ts",
140
+ " - Credentials: encrypted-store.ts / ces-credential-client.ts",
141
+ " - Signing keys: secure-keys.ts / credential-backend.ts",
142
+ "",
143
+ "If this file is a new abstraction backend, add it to ALLOWED_FILES",
144
+ "in this guard test. Otherwise, use the appropriate abstraction layer",
145
+ "or gate the access behind !getIsContainerized().",
146
+ "",
147
+ "Violations:",
148
+ ...violations.map((f) => ` - ${f}`),
149
+ ].join("\n");
150
+
151
+ expect(violations, message).toEqual([]);
152
+ }
153
+ });
154
+ }
155
+ });
@@ -375,6 +375,24 @@ Examples:
375
375
  targetId: summaryId,
376
376
  });
377
377
  }
378
+ for (const obsId of result.deletedObservationIds) {
379
+ enqueueMemoryJob("delete_qdrant_vectors", {
380
+ targetType: "observation",
381
+ targetId: obsId,
382
+ });
383
+ }
384
+ for (const chunkId of result.deletedChunkIds) {
385
+ enqueueMemoryJob("delete_qdrant_vectors", {
386
+ targetType: "chunk",
387
+ targetId: chunkId,
388
+ });
389
+ }
390
+ for (const episodeId of result.deletedEpisodeIds) {
391
+ enqueueMemoryJob("delete_qdrant_vectors", {
392
+ targetType: "episode",
393
+ targetId: episodeId,
394
+ });
395
+ }
378
396
 
379
397
  log.info(
380
398
  `Wiped conversation "${conversation.title ?? "Untitled"}". ` +
@@ -136,6 +136,7 @@ export async function getProviderConnection(
136
136
  provider: MessagingProvider,
137
137
  account?: string,
138
138
  ): Promise<OAuthConnection | string> {
139
+ if (provider.resolveConnection) return provider.resolveConnection(account);
139
140
  if (await provider.isConnected?.()) return "";
140
141
  return resolveOAuthConnection(provider.credentialService, { account });
141
142
  }
@@ -52,6 +52,10 @@
52
52
  "type": "object",
53
53
  "description": "Additional routing metadata (e.g. preferred channel identifiers)"
54
54
  },
55
+ "quiet": {
56
+ "type": "boolean",
57
+ "description": "When true, suppress completion notifications for this schedule. The job still runs and produces output, but no notification or conversation message is sent on completion. Useful for high-frequency recurring jobs that report findings separately. Defaults to false."
58
+ },
55
59
  "activity": {
56
60
  "type": "string",
57
61
  "description": "Brief non-technical explanation of why this tool is being called"
@@ -139,6 +143,10 @@
139
143
  "type": "object",
140
144
  "description": "Additional routing metadata (e.g. preferred channel identifiers)"
141
145
  },
146
+ "quiet": {
147
+ "type": "boolean",
148
+ "description": "When true, suppress completion notifications for this schedule. Useful for high-frequency jobs that report findings separately."
149
+ },
142
150
  "activity": {
143
151
  "type": "string",
144
152
  "description": "Brief non-technical explanation of why this tool is being called"
@@ -10,6 +10,7 @@ import {
10
10
  import { tmpdir } from "node:os";
11
11
  import { extname, join } from "node:path";
12
12
 
13
+ import { OpenAIWhisperProvider } from "../../../../providers/speech-to-text/openai-whisper.js";
13
14
  import { getProviderKeyAsync } from "../../../../security/secure-keys.js";
14
15
  import type {
15
16
  ToolContext,
@@ -168,12 +169,19 @@ async function transcribeViaApi(
168
169
  apiKey: string,
169
170
  context: ToolContext,
170
171
  ): Promise<string> {
172
+ const provider = new OpenAIWhisperProvider(apiKey);
171
173
  const duration = await getAudioDuration(audioPath);
172
174
  const fileSize = Bun.file(audioPath).size;
173
175
 
174
176
  // If small enough, send directly
175
177
  if (fileSize <= WHISPER_API_MAX_BYTES) {
176
- return await whisperApiRequest(audioPath, apiKey);
178
+ const audioBuffer = await readFile(audioPath);
179
+ const result = await provider.transcribe(
180
+ audioBuffer,
181
+ "audio/wav",
182
+ AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
183
+ );
184
+ return result.text;
177
185
  }
178
186
 
179
187
  // Split into chunks for large files
@@ -199,8 +207,13 @@ async function transcribeViaApi(
199
207
  for (let i = 0; i < chunks.length; i++) {
200
208
  if (context.signal?.aborted) throw new Error("Cancelled");
201
209
  context.onOutput?.(` Transcribing chunk ${i + 1}/${chunks.length}...\n`);
202
- const text = await whisperApiRequest(chunks[i], apiKey);
203
- if (text) parts.push(text);
210
+ const audioBuffer = await readFile(chunks[i]);
211
+ const result = await provider.transcribe(
212
+ audioBuffer,
213
+ "audio/wav",
214
+ AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
215
+ );
216
+ if (result.text) parts.push(result.text);
204
217
  }
205
218
 
206
219
  return parts.join(" ");
@@ -213,40 +226,6 @@ async function transcribeViaApi(
213
226
  }
214
227
  }
215
228
 
216
- async function whisperApiRequest(
217
- audioPath: string,
218
- apiKey: string,
219
- ): Promise<string> {
220
- const audioData = await readFile(audioPath);
221
- const formData = new FormData();
222
- formData.append(
223
- "file",
224
- new Blob([audioData], { type: "audio/wav" }),
225
- "audio.wav",
226
- );
227
- formData.append("model", "whisper-1");
228
-
229
- const response = await fetch(
230
- "https://api.openai.com/v1/audio/transcriptions",
231
- {
232
- method: "POST",
233
- headers: { Authorization: `Bearer ${apiKey}` },
234
- body: formData,
235
- signal: AbortSignal.timeout(API_REQUEST_TIMEOUT_MS),
236
- },
237
- );
238
-
239
- if (!response.ok) {
240
- const body = await response.text().catch(() => "");
241
- throw new Error(
242
- `Whisper API error (${response.status}): ${body.slice(0, 300)}`,
243
- );
244
- }
245
-
246
- const result = (await response.json()) as { text?: string };
247
- return result.text?.trim() ?? "";
248
- }
249
-
250
229
  // ---------------------------------------------------------------------------
251
230
  // Local mode - whisper.cpp
252
231
  // ---------------------------------------------------------------------------
@@ -54,6 +54,15 @@ export function getIsContainerized(): boolean {
54
54
  return flag("IS_CONTAINERIZED");
55
55
  }
56
56
 
57
+ /**
58
+ * WORKSPACE_DIR — string, default: undefined
59
+ * When set, overrides the default workspace directory. Used in containerized
60
+ * deployments where the workspace is a separate volume.
61
+ */
62
+ export function getWorkspaceDirOverride(): string | undefined {
63
+ return str("WORKSPACE_DIR");
64
+ }
65
+
57
66
  // ── Known env var names ──────────────────────────────────────────────────────
58
67
 
59
68
  /**
@@ -288,6 +288,14 @@
288
288
  "label": "Inline Skill Command Expansion",
289
289
  "description": "Enable secure inline skill command expansion via !`command` syntax, with version-pinned approval and sandboxed execution at skill load time",
290
290
  "defaultEnabled": true
291
+ },
292
+ {
293
+ "id": "channel-voice-transcription",
294
+ "scope": "assistant",
295
+ "key": "feature_flags.channel-voice-transcription.enabled",
296
+ "label": "Channel Voice Transcription",
297
+ "description": "Auto-transcribe voice/audio messages received from channels (Telegram, WhatsApp) before processing",
298
+ "defaultEnabled": true
291
299
  }
292
300
  ]
293
301
  }
@@ -29,7 +29,6 @@ export const API_KEY_PROVIDERS = [
29
29
  "fireworks",
30
30
  "openrouter",
31
31
  "brave",
32
- "elevenlabs",
33
32
  "perplexity",
34
33
  ] as const;
35
34
 
@@ -71,7 +71,7 @@ export const MemorySimplifiedConfigSchema = z
71
71
  .boolean({
72
72
  error: "memory.simplified.enabled must be a boolean",
73
73
  })
74
- .default(false)
74
+ .default(true)
75
75
  .describe("Whether the simplified memory system is enabled"),
76
76
  brief: MemorySimplifiedBriefConfigSchema.default(
77
77
  MemorySimplifiedBriefConfigSchema.parse({}),