@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,148 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mutable mock state
5
+ // ---------------------------------------------------------------------------
6
+
7
+ let mockManagedProxyCtx = {
8
+ enabled: false,
9
+ platformBaseUrl: "",
10
+ assistantApiKey: "",
11
+ };
12
+ let mockAssistantId = "";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Module mocks
16
+ // ---------------------------------------------------------------------------
17
+
18
+ mock.module("../providers/managed-proxy/context.js", () => ({
19
+ resolveManagedProxyContext: async () => mockManagedProxyCtx,
20
+ }));
21
+
22
+ mock.module("../config/env.js", () => ({
23
+ getPlatformAssistantId: () => mockAssistantId,
24
+ }));
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Import under test (after mocks)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ import { VellumPlatformClient } from "./client.js";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Tests
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe("VellumPlatformClient", () => {
37
+ let originalFetch: typeof globalThis.fetch;
38
+
39
+ beforeEach(() => {
40
+ originalFetch = globalThis.fetch;
41
+ mockManagedProxyCtx = {
42
+ enabled: true,
43
+ platformBaseUrl: "https://platform.example.com",
44
+ assistantApiKey: "sk-test-key",
45
+ };
46
+ mockAssistantId = "asst-123";
47
+ });
48
+
49
+ afterEach(() => {
50
+ globalThis.fetch = originalFetch;
51
+ });
52
+
53
+ describe("create()", () => {
54
+ test("returns a client when all prerequisites are met", async () => {
55
+ const client = await VellumPlatformClient.create();
56
+ expect(client).not.toBeNull();
57
+ expect(client!.baseUrl).toBe("https://platform.example.com");
58
+ expect(client!.assistantApiKey).toBe("sk-test-key");
59
+ expect(client!.platformAssistantId).toBe("asst-123");
60
+ });
61
+
62
+ test("returns null when managed proxy is not enabled", async () => {
63
+ mockManagedProxyCtx = {
64
+ enabled: false,
65
+ platformBaseUrl: "",
66
+ assistantApiKey: "",
67
+ };
68
+
69
+ const client = await VellumPlatformClient.create();
70
+ expect(client).toBeNull();
71
+ });
72
+
73
+ test("returns client with empty assistantId when assistant ID is missing", async () => {
74
+ mockAssistantId = "";
75
+
76
+ const client = await VellumPlatformClient.create();
77
+ expect(client).not.toBeNull();
78
+ expect(client!.platformAssistantId).toBe("");
79
+ });
80
+
81
+ test("strips trailing slash from platform base URL", async () => {
82
+ mockManagedProxyCtx.platformBaseUrl = "https://platform.example.com///";
83
+
84
+ const client = await VellumPlatformClient.create();
85
+ expect(client!.baseUrl).toBe("https://platform.example.com");
86
+ });
87
+ });
88
+
89
+ describe("fetch()", () => {
90
+ test("prepends base URL to path", async () => {
91
+ globalThis.fetch = mock(async (url: string | URL | Request) => {
92
+ expect(String(url)).toBe(
93
+ "https://platform.example.com/v1/some/endpoint/",
94
+ );
95
+ return new Response("ok", { status: 200 });
96
+ }) as unknown as typeof globalThis.fetch;
97
+
98
+ const client = await VellumPlatformClient.create();
99
+ await client!.fetch("/v1/some/endpoint/");
100
+ });
101
+
102
+ test("injects Api-Key auth header", async () => {
103
+ globalThis.fetch = mock(
104
+ async (_url: string | URL | Request, init?: RequestInit) => {
105
+ const headers = new Headers(init?.headers);
106
+ expect(headers.get("Authorization")).toBe("Api-Key sk-test-key");
107
+ return new Response("ok", { status: 200 });
108
+ },
109
+ ) as unknown as typeof globalThis.fetch;
110
+
111
+ const client = await VellumPlatformClient.create();
112
+ await client!.fetch("/v1/test/");
113
+ });
114
+
115
+ test("preserves caller-provided headers", async () => {
116
+ globalThis.fetch = mock(
117
+ async (_url: string | URL | Request, init?: RequestInit) => {
118
+ const headers = new Headers(init?.headers);
119
+ expect(headers.get("Content-Type")).toBe("application/json");
120
+ expect(headers.get("Authorization")).toBe("Api-Key sk-test-key");
121
+ return new Response("ok", { status: 200 });
122
+ },
123
+ ) as unknown as typeof globalThis.fetch;
124
+
125
+ const client = await VellumPlatformClient.create();
126
+ await client!.fetch("/v1/test/", {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ });
130
+ });
131
+
132
+ test("passes through request init options", async () => {
133
+ globalThis.fetch = mock(
134
+ async (_url: string | URL | Request, init?: RequestInit) => {
135
+ expect(init?.method).toBe("POST");
136
+ expect(init?.body).toBe('{"key":"value"}');
137
+ return new Response("ok", { status: 200 });
138
+ },
139
+ ) as unknown as typeof globalThis.fetch;
140
+
141
+ const client = await VellumPlatformClient.create();
142
+ await client!.fetch("/v1/test/", {
143
+ method: "POST",
144
+ body: '{"key":"value"}',
145
+ });
146
+ });
147
+ });
148
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Centralized platform API client.
3
+ *
4
+ * Owns managed proxy context resolution, prerequisite validation, and
5
+ * authenticated fetch for all platform API calls that use Api-Key auth.
6
+ */
7
+
8
+ import { getPlatformAssistantId } from "../config/env.js";
9
+ import { resolveManagedProxyContext } from "../providers/managed-proxy/context.js";
10
+
11
+ export class VellumPlatformClient {
12
+ private readonly platformBaseUrl: string;
13
+ private readonly apiKey: string;
14
+ private readonly assistantId: string;
15
+
16
+ private constructor(
17
+ platformBaseUrl: string,
18
+ apiKey: string,
19
+ assistantId: string,
20
+ ) {
21
+ this.platformBaseUrl = platformBaseUrl.replace(/\/+$/, "");
22
+ this.apiKey = apiKey;
23
+ this.assistantId = assistantId;
24
+ }
25
+
26
+ /**
27
+ * Create a platform client by resolving managed proxy context.
28
+ *
29
+ * Returns `null` when auth prerequisites are missing (not logged in, no API
30
+ * key). The assistant ID is resolved but not required — callers that need it
31
+ * should check `platformAssistantId` themselves.
32
+ */
33
+ static async create(): Promise<VellumPlatformClient | null> {
34
+ const ctx = await resolveManagedProxyContext();
35
+ if (!ctx.enabled) return null;
36
+
37
+ const assistantId = getPlatformAssistantId();
38
+
39
+ return new VellumPlatformClient(
40
+ ctx.platformBaseUrl,
41
+ ctx.assistantApiKey,
42
+ assistantId,
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Authenticated fetch against the platform API.
48
+ *
49
+ * Prepends `platformBaseUrl` to `path` and injects the `Api-Key` auth header.
50
+ * Callers handle response parsing and domain-specific error mapping.
51
+ */
52
+ async fetch(path: string, init?: RequestInit): Promise<Response> {
53
+ const url = `${this.platformBaseUrl}${path}`;
54
+ const headers = new Headers(init?.headers);
55
+ headers.set("Authorization", `Api-Key ${this.apiKey}`);
56
+
57
+ return fetch(url, { ...init, headers });
58
+ }
59
+
60
+ get baseUrl(): string {
61
+ return this.platformBaseUrl;
62
+ }
63
+
64
+ get assistantApiKey(): string {
65
+ return this.apiKey;
66
+ }
67
+
68
+ get platformAssistantId(): string {
69
+ return this.assistantId;
70
+ }
71
+ }
@@ -0,0 +1,190 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Module mocks (must precede dynamic imports)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ let mockOpenAIKey: string | undefined;
8
+
9
+ mock.module("../../security/secure-keys.js", () => ({
10
+ getProviderKeyAsync: async (provider: string) =>
11
+ provider === "openai" ? mockOpenAIKey : undefined,
12
+ }));
13
+
14
+ // Dynamic import so the mock is in effect when resolve.ts loads.
15
+ const { resolveSpeechToTextProvider } = await import("./resolve.js");
16
+
17
+ import { OpenAIWhisperProvider } from "./openai-whisper.js";
18
+
19
+ const TEST_API_KEY = "sk-test-key-for-unit-tests";
20
+
21
+ describe("OpenAIWhisperProvider", () => {
22
+ let originalFetch: typeof globalThis.fetch;
23
+
24
+ beforeEach(() => {
25
+ originalFetch = globalThis.fetch;
26
+ });
27
+
28
+ afterEach(() => {
29
+ globalThis.fetch = originalFetch;
30
+ });
31
+
32
+ test("successful transcription returns text", async () => {
33
+ globalThis.fetch = (async (
34
+ _url: string | URL | Request,
35
+ _init?: RequestInit,
36
+ ) => {
37
+ return new Response(JSON.stringify({ text: " Hello, world! " }), {
38
+ status: 200,
39
+ headers: { "Content-Type": "application/json" },
40
+ });
41
+ }) as typeof fetch;
42
+
43
+ const provider = new OpenAIWhisperProvider(TEST_API_KEY);
44
+ const result = await provider.transcribe(
45
+ Buffer.from("fake-audio"),
46
+ "audio/ogg",
47
+ );
48
+
49
+ expect(result).toEqual({ text: "Hello, world!" });
50
+ });
51
+
52
+ test("API error throws with status and partial body", async () => {
53
+ const errorBody = JSON.stringify({
54
+ error: {
55
+ message: "Invalid API key provided",
56
+ type: "invalid_request_error",
57
+ },
58
+ });
59
+
60
+ globalThis.fetch = (async (
61
+ _url: string | URL | Request,
62
+ _init?: RequestInit,
63
+ ) => {
64
+ return new Response(errorBody, {
65
+ status: 401,
66
+ headers: { "Content-Type": "application/json" },
67
+ });
68
+ }) as typeof fetch;
69
+
70
+ const provider = new OpenAIWhisperProvider(TEST_API_KEY);
71
+
72
+ await expect(
73
+ provider.transcribe(Buffer.from("fake-audio"), "audio/wav"),
74
+ ).rejects.toThrow("Whisper API error (401)");
75
+ });
76
+
77
+ test("sends correct FormData structure (model=whisper-1, file blob with correct MIME)", async () => {
78
+ let capturedBody: FormData | undefined;
79
+ let capturedHeaders: HeadersInit | undefined;
80
+
81
+ globalThis.fetch = (async (
82
+ url: string | URL | Request,
83
+ init?: RequestInit,
84
+ ) => {
85
+ capturedBody = init?.body as FormData;
86
+ capturedHeaders = init?.headers;
87
+ expect(url).toBe("https://api.openai.com/v1/audio/transcriptions");
88
+ expect(init?.method).toBe("POST");
89
+
90
+ return new Response(JSON.stringify({ text: "transcribed" }), {
91
+ status: 200,
92
+ headers: { "Content-Type": "application/json" },
93
+ });
94
+ }) as typeof fetch;
95
+
96
+ const provider = new OpenAIWhisperProvider(TEST_API_KEY);
97
+ await provider.transcribe(Buffer.from("fake-audio"), "audio/mpeg");
98
+
99
+ // Verify authorization header
100
+ expect(capturedHeaders).toEqual({
101
+ Authorization: `Bearer ${TEST_API_KEY}`,
102
+ });
103
+
104
+ // Verify FormData contents
105
+ expect(capturedBody).toBeInstanceOf(FormData);
106
+ const model = capturedBody!.get("model");
107
+ expect(model).toBe("whisper-1");
108
+
109
+ const file = capturedBody!.get("file");
110
+ expect(file).toBeInstanceOf(Blob);
111
+ expect((file as Blob).type).toBe("audio/mpeg");
112
+ });
113
+
114
+ test("returns empty text when API returns empty text field", async () => {
115
+ globalThis.fetch = (async () => {
116
+ return new Response(JSON.stringify({ text: "" }), {
117
+ status: 200,
118
+ headers: { "Content-Type": "application/json" },
119
+ });
120
+ }) as unknown as typeof fetch;
121
+
122
+ const provider = new OpenAIWhisperProvider(TEST_API_KEY);
123
+ const result = await provider.transcribe(
124
+ Buffer.from("silence"),
125
+ "audio/wav",
126
+ );
127
+
128
+ expect(result).toEqual({ text: "" });
129
+ });
130
+
131
+ test("returns empty text when API response has no text property", async () => {
132
+ globalThis.fetch = (async () => {
133
+ return new Response(JSON.stringify({}), {
134
+ status: 200,
135
+ headers: { "Content-Type": "application/json" },
136
+ });
137
+ }) as unknown as typeof fetch;
138
+
139
+ const provider = new OpenAIWhisperProvider(TEST_API_KEY);
140
+ const result = await provider.transcribe(
141
+ Buffer.from("silence"),
142
+ "audio/wav",
143
+ );
144
+
145
+ expect(result).toEqual({ text: "" });
146
+ });
147
+
148
+ test("error body is truncated to 300 characters", async () => {
149
+ const longBody = "x".repeat(500);
150
+
151
+ globalThis.fetch = (async () => {
152
+ return new Response(longBody, { status: 500 });
153
+ }) as unknown as typeof fetch;
154
+
155
+ const provider = new OpenAIWhisperProvider(TEST_API_KEY);
156
+
157
+ try {
158
+ await provider.transcribe(Buffer.from("audio"), "audio/wav");
159
+ expect.unreachable("should have thrown");
160
+ } catch (err) {
161
+ const msg = (err as Error).message;
162
+ expect(msg).toContain("Whisper API error (500)");
163
+ // The body portion should be at most 300 chars
164
+ const bodyPart = msg.replace("Whisper API error (500): ", "");
165
+ expect(bodyPart.length).toBeLessThanOrEqual(300);
166
+ }
167
+ });
168
+ });
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // resolveSpeechToTextProvider
172
+ // ---------------------------------------------------------------------------
173
+
174
+ describe("resolveSpeechToTextProvider", () => {
175
+ beforeEach(() => {
176
+ mockOpenAIKey = undefined;
177
+ });
178
+
179
+ test("returns null when no OpenAI key is configured", async () => {
180
+ mockOpenAIKey = undefined;
181
+ const provider = await resolveSpeechToTextProvider();
182
+ expect(provider).toBeNull();
183
+ });
184
+
185
+ test("returns an OpenAIWhisperProvider when key exists", async () => {
186
+ mockOpenAIKey = "sk-real-key";
187
+ const provider = await resolveSpeechToTextProvider();
188
+ expect(provider).toBeInstanceOf(OpenAIWhisperProvider);
189
+ });
190
+ });
@@ -0,0 +1,68 @@
1
+ import type { SpeechToTextProvider, SpeechToTextResult } from "./types.js";
2
+
3
+ const WHISPER_API_URL = "https://api.openai.com/v1/audio/transcriptions";
4
+ const DEFAULT_TIMEOUT_MS = 60_000;
5
+
6
+ /**
7
+ * Derive a filename extension from a MIME type so the Whisper API can detect
8
+ * the audio format. Falls back to "audio" when the MIME type is unrecognised.
9
+ */
10
+ function extensionFromMime(mimeType: string): string {
11
+ const map: Record<string, string> = {
12
+ "audio/wav": "wav",
13
+ "audio/x-wav": "wav",
14
+ "audio/mpeg": "mp3",
15
+ "audio/mp3": "mp3",
16
+ "audio/ogg": "ogg",
17
+ "audio/opus": "opus",
18
+ "audio/webm": "webm",
19
+ "audio/mp4": "m4a",
20
+ "audio/x-m4a": "m4a",
21
+ "audio/flac": "flac",
22
+ };
23
+ const base = mimeType.split(";")[0].trim().toLowerCase();
24
+ return map[base] ?? "audio";
25
+ }
26
+
27
+ export class OpenAIWhisperProvider implements SpeechToTextProvider {
28
+ private readonly apiKey: string;
29
+
30
+ constructor(apiKey: string) {
31
+ this.apiKey = apiKey;
32
+ }
33
+
34
+ async transcribe(
35
+ audio: Buffer,
36
+ mimeType: string,
37
+ signal?: AbortSignal,
38
+ ): Promise<SpeechToTextResult> {
39
+ const ext = extensionFromMime(mimeType);
40
+
41
+ const formData = new FormData();
42
+ formData.append(
43
+ "file",
44
+ new Blob([new Uint8Array(audio)], { type: mimeType }),
45
+ `audio.${ext}`,
46
+ );
47
+ formData.append("model", "whisper-1");
48
+
49
+ const effectiveSignal = signal ?? AbortSignal.timeout(DEFAULT_TIMEOUT_MS);
50
+
51
+ const response = await fetch(WHISPER_API_URL, {
52
+ method: "POST",
53
+ headers: { Authorization: `Bearer ${this.apiKey}` },
54
+ body: formData,
55
+ signal: effectiveSignal,
56
+ });
57
+
58
+ if (!response.ok) {
59
+ const body = await response.text().catch(() => "");
60
+ throw new Error(
61
+ `Whisper API error (${response.status}): ${body.slice(0, 300)}`,
62
+ );
63
+ }
64
+
65
+ const result = (await response.json()) as { text?: string };
66
+ return { text: result.text?.trim() ?? "" };
67
+ }
68
+ }
@@ -0,0 +1,9 @@
1
+ import { getProviderKeyAsync } from "../../security/secure-keys.js";
2
+ import { OpenAIWhisperProvider } from "./openai-whisper.js";
3
+ import type { SpeechToTextProvider } from "./types.js";
4
+
5
+ export async function resolveSpeechToTextProvider(): Promise<SpeechToTextProvider | null> {
6
+ const apiKey = await getProviderKeyAsync("openai");
7
+ if (!apiKey) return null;
8
+ return new OpenAIWhisperProvider(apiKey);
9
+ }
@@ -0,0 +1,17 @@
1
+ export interface SpeechToTextResult {
2
+ text: string;
3
+ }
4
+
5
+ export interface SpeechToTextProvider {
6
+ /**
7
+ * Transcribe audio from a Buffer.
8
+ * @param audio - Raw audio data (WAV, OGG, MP3, etc.)
9
+ * @param mimeType - MIME type of the audio data
10
+ * @param signal - Optional abort signal for cancellation
11
+ */
12
+ transcribe(
13
+ audio: Buffer,
14
+ mimeType: string,
15
+ signal?: AbortSignal,
16
+ ): Promise<SpeechToTextResult>;
17
+ }
@@ -128,7 +128,7 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
128
128
  { endpoint: "messages:POST", scopes: ["chat.write"] },
129
129
  { endpoint: "btw", scopes: ["chat.write"] },
130
130
  { endpoint: "conversations", scopes: ["chat.read"] },
131
- { endpoint: "conversations:DELETE", scopes: ["chat.write"] },
131
+ { endpoint: "conversations:POST", scopes: ["chat.write"] },
132
132
  { endpoint: "conversations/fork", scopes: ["chat.write"] },
133
133
  { endpoint: "conversations/switch", scopes: ["chat.write"] },
134
134
  { endpoint: "conversations/name", scopes: ["chat.write"] },
@@ -348,6 +348,7 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
348
348
  { endpoint: "config/embeddings:PUT", scopes: ["settings.write"] },
349
349
 
350
350
  // Conversation management
351
+ { endpoint: "conversations:DELETE", scopes: ["chat.write"] },
351
352
  { endpoint: "conversations/wipe", scopes: ["chat.write"] },
352
353
  { endpoint: "conversations/reorder", scopes: ["chat.write"] },
353
354
 
@@ -470,6 +471,14 @@ for (const { endpoint, scopes } of ACTOR_ENDPOINTS) {
470
471
  });
471
472
  }
472
473
 
474
+ // Clear-all conversations: elevated to settings.write (destructive bulk operation).
475
+ // Uses a distinct key so the single-conversation DELETE (conversations:DELETE)
476
+ // retains the lower chat.write scope.
477
+ registerPolicy("conversations/clear-all", {
478
+ requiredScopes: ["settings.write"],
479
+ allowedPrincipalTypes: ["actor", "svc_gateway", "svc_daemon", "local"],
480
+ });
481
+
473
482
  // Channel inbound: gateway-only
474
483
  registerPolicy("channels/inbound", {
475
484
  requiredScopes: ["ingress.write"],
@@ -208,8 +208,8 @@ const log = getLogger("runtime-http");
208
208
  const DEFAULT_PORT = 7821;
209
209
  const DEFAULT_HOSTNAME = "127.0.0.1";
210
210
 
211
- /** Global hard cap on request body size (150 MB — accommodates base64-encoded 100 MB attachments). */
212
- const MAX_REQUEST_BODY_BYTES = 150 * 1024 * 1024;
211
+ /** Global hard cap on request body size (512 MB — accommodates large .vbundle backup imports). */
212
+ const MAX_REQUEST_BODY_BYTES = 512 * 1024 * 1024;
213
213
 
214
214
  export class RuntimeHttpServer {
215
215
  private server: ReturnType<typeof Bun.serve> | null = null;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Route handlers for conversation management operations.
3
3
  *
4
+ * POST /v1/conversations — create a new conversation
4
5
  * POST /v1/conversations/switch — switch to an existing conversation
5
6
  * POST /v1/conversations/fork — fork an existing conversation
6
7
  * PATCH /v1/conversations/:id/name — rename a conversation
@@ -19,7 +20,9 @@ import {
19
20
  PRIVATE_CONVERSATION_FORK_ERROR,
20
21
  wipeConversation,
21
22
  } from "../../memory/conversation-crud.js";
23
+ import { updateConversationTitle } from "../../memory/conversation-crud.js";
22
24
  import {
25
+ getOrCreateConversation,
23
26
  resolveConversationId,
24
27
  setConversationKeyIfAbsent,
25
28
  } from "../../memory/conversation-key-store.js";
@@ -66,6 +69,44 @@ export function conversationManagementRouteDefinitions(
66
69
  deps: ConversationManagementDeps,
67
70
  ): RouteDefinition[] {
68
71
  return [
72
+ {
73
+ endpoint: "conversations",
74
+ method: "POST",
75
+ policyKey: "conversations",
76
+ handler: async ({ req }) => {
77
+ let body: { conversationKey?: string; conversationType?: string } = {};
78
+ try {
79
+ body = (await req.json()) as typeof body;
80
+ } catch {
81
+ // Empty or malformed body — fall through with defaults.
82
+ }
83
+ const conversationKey = body.conversationKey ?? crypto.randomUUID();
84
+ const requestedType =
85
+ body.conversationType === "private" ? "private" : "standard";
86
+ const result = getOrCreateConversation(conversationKey, {
87
+ conversationType: requestedType,
88
+ });
89
+ if (result.created) {
90
+ updateConversationTitle(result.conversationId, "New Conversation");
91
+ }
92
+ log.info(
93
+ {
94
+ conversationId: result.conversationId,
95
+ conversationKey,
96
+ created: result.created,
97
+ },
98
+ "Created conversation via POST",
99
+ );
100
+ return Response.json(
101
+ {
102
+ id: result.conversationId,
103
+ conversationKey,
104
+ conversationType: result.conversationType,
105
+ },
106
+ { status: result.created ? 201 : 200 },
107
+ );
108
+ },
109
+ },
69
110
  {
70
111
  endpoint: "conversations/fork",
71
112
  method: "POST",
@@ -185,8 +226,17 @@ export function conversationManagementRouteDefinitions(
185
226
  {
186
227
  endpoint: "conversations",
187
228
  method: "DELETE",
188
- policyKey: "conversations",
189
- handler: () => {
229
+ policyKey: "conversations/clear-all",
230
+ handler: ({ req }) => {
231
+ const confirm = req.headers.get("x-confirm-destructive");
232
+ if (confirm !== "clear-all-conversations") {
233
+ return httpError(
234
+ "BAD_REQUEST",
235
+ "DELETE /v1/conversations permanently deletes ALL conversations, messages, and memory. " +
236
+ "To confirm, set header X-Confirm-Destructive: clear-all-conversations",
237
+ 400,
238
+ );
239
+ }
190
240
  deps.clearAllConversations();
191
241
  return new Response(null, { status: 204 });
192
242
  },
@@ -225,6 +275,24 @@ export function conversationManagementRouteDefinitions(
225
275
  targetId: summaryId,
226
276
  });
227
277
  }
278
+ for (const obsId of result.deletedObservationIds) {
279
+ enqueueMemoryJob("delete_qdrant_vectors", {
280
+ targetType: "observation",
281
+ targetId: obsId,
282
+ });
283
+ }
284
+ for (const chunkId of result.deletedChunkIds) {
285
+ enqueueMemoryJob("delete_qdrant_vectors", {
286
+ targetType: "chunk",
287
+ targetId: chunkId,
288
+ });
289
+ }
290
+ for (const episodeId of result.deletedEpisodeIds) {
291
+ enqueueMemoryJob("delete_qdrant_vectors", {
292
+ targetType: "episode",
293
+ targetId: episodeId,
294
+ });
295
+ }
228
296
  log.info(
229
297
  {
230
298
  conversationId: resolvedId,
@@ -281,6 +349,24 @@ export function conversationManagementRouteDefinitions(
281
349
  targetId: summaryId,
282
350
  });
283
351
  }
352
+ for (const obsId of deleted.deletedObservationIds) {
353
+ enqueueMemoryJob("delete_qdrant_vectors", {
354
+ targetType: "observation",
355
+ targetId: obsId,
356
+ });
357
+ }
358
+ for (const chunkId of deleted.deletedChunkIds) {
359
+ enqueueMemoryJob("delete_qdrant_vectors", {
360
+ targetType: "chunk",
361
+ targetId: chunkId,
362
+ });
363
+ }
364
+ for (const episodeId of deleted.deletedEpisodeIds) {
365
+ enqueueMemoryJob("delete_qdrant_vectors", {
366
+ targetType: "episode",
367
+ targetId: episodeId,
368
+ });
369
+ }
284
370
  log.info({ conversationId: resolvedId }, "Deleted conversation");
285
371
  return new Response(null, { status: 204 });
286
372
  },