@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
@@ -38,8 +38,13 @@ class FileReadTool implements Tool {
38
38
  type: "number",
39
39
  description: "Maximum number of lines to read",
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"],
47
+ required: ["path", "activity"],
43
48
  },
44
49
  };
45
50
  }
@@ -28,8 +28,13 @@ class FileWriteTool implements Tool {
28
28
  type: "string",
29
29
  description: "The content to write to the file",
30
30
  },
31
+ activity: {
32
+ type: "string",
33
+ description:
34
+ "Brief non-technical explanation of what you are doing and why, shown to the user as a status update.",
35
+ },
31
36
  },
32
- required: ["path", "content"],
37
+ required: ["path", "content", "activity"],
33
38
  },
34
39
  };
35
40
  }
@@ -2,6 +2,8 @@ import { and, eq, ne } from "drizzle-orm";
2
2
  import { v4 as uuid } from "uuid";
3
3
 
4
4
  import type { AssistantConfig } from "../../config/types.js";
5
+ import { buildArchiveRecall } from "../../memory/archive-recall.js";
6
+ import { insertObservation } from "../../memory/archive-store.js";
5
7
  import { getDb } from "../../memory/db.js";
6
8
  import { computeMemoryFingerprint } from "../../memory/fingerprint.js";
7
9
  import { enqueueMemoryJob } from "../../memory/jobs-store.js";
@@ -18,7 +20,7 @@ const log = getLogger("memory-tools");
18
20
 
19
21
  export async function handleMemorySave(
20
22
  args: Record<string, unknown>,
21
- _config: AssistantConfig,
23
+ config: AssistantConfig,
22
24
  conversationId: string,
23
25
  messageId: string | undefined,
24
26
  scopeId: string = "default",
@@ -63,6 +65,19 @@ export async function handleMemorySave(
63
65
  ? truncate(args.subject.trim(), 80, "")
64
66
  : inferSubjectFromStatement(statement.trim());
65
67
 
68
+ // When simplified memory is enabled, save directly to the simplified
69
+ // observation/chunk tables instead of the legacy memory_items table.
70
+ if (config.memory.simplified.enabled) {
71
+ return handleSimplifiedMemorySave(
72
+ kind,
73
+ subject,
74
+ statement.trim(),
75
+ conversationId,
76
+ messageId,
77
+ scopeId,
78
+ );
79
+ }
80
+
66
81
  try {
67
82
  const db = getDb();
68
83
  const id = uuid();
@@ -275,6 +290,12 @@ export async function handleMemoryRecall(
275
290
  ? args.scope.trim()
276
291
  : "default";
277
292
 
293
+ // When simplified memory is enabled, use the archive recall path
294
+ // instead of the legacy hybrid retriever.
295
+ if (config.memory.simplified.enabled) {
296
+ return handleSimplifiedMemoryRecall(query.trim(), scopeId ?? "default");
297
+ }
298
+
278
299
  // Scope policy: "conversation" means strict (only that scope),
279
300
  // anything else allows fallback to the default scope.
280
301
  const scopePolicyOverride: ScopePolicyOverride | undefined = scopeId
@@ -411,6 +432,113 @@ export async function handleMemoryDelete(
411
432
  }
412
433
  }
413
434
 
435
+ // ── Simplified memory helpers ────────────────────────────────────────
436
+
437
+ /**
438
+ * Save a memory item as an observation + chunk in the simplified system.
439
+ * This is used when simplified memory is enabled instead of writing to
440
+ * the legacy memory_items table.
441
+ */
442
+ function handleSimplifiedMemorySave(
443
+ kind: string,
444
+ subject: string,
445
+ statement: string,
446
+ conversationId: string,
447
+ messageId: string | undefined,
448
+ scopeId: string,
449
+ ): ToolExecutionResult {
450
+ try {
451
+ const trimmedStatement = truncate(statement, 500, "");
452
+ const content = `[${kind}] ${subject}: ${trimmedStatement}`;
453
+
454
+ const result = insertObservation({
455
+ conversationId,
456
+ messageId: messageId ?? null,
457
+ role: "user",
458
+ content,
459
+ scopeId,
460
+ modality: "text",
461
+ source: "tool:memory_save",
462
+ });
463
+
464
+ log.debug(
465
+ {
466
+ observationId: result.observationId,
467
+ chunkId: result.chunkId,
468
+ kind,
469
+ subject,
470
+ conversationId,
471
+ messageId,
472
+ },
473
+ "Memory saved via simplified system",
474
+ );
475
+
476
+ return {
477
+ content: `Saved to memory (ID: ${result.observationId}).\nKind: ${kind}\nSubject: ${subject}\nStatement: ${trimmedStatement}`,
478
+ isError: false,
479
+ };
480
+ } catch (err) {
481
+ const msg = err instanceof Error ? err.message : String(err);
482
+ log.error({ err }, "simplified memory_save failed");
483
+ return { content: `Error: Failed to save memory: ${msg}`, isError: true };
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Recall memories using the simplified archive recall path instead of
489
+ * the legacy hybrid retriever.
490
+ */
491
+ function handleSimplifiedMemoryRecall(
492
+ query: string,
493
+ scopeId: string,
494
+ ): ToolExecutionResult {
495
+ try {
496
+ const recallResult = buildArchiveRecall(scopeId, query);
497
+
498
+ if (recallResult.bullets.length === 0) {
499
+ return {
500
+ content: JSON.stringify({
501
+ text: "No matching memories found.",
502
+ resultCount: 0,
503
+ degraded: false,
504
+ items: [],
505
+ sources: { semantic: 0, recency: 0 },
506
+ }),
507
+ isError: false,
508
+ };
509
+ }
510
+
511
+ const items = recallResult.bullets.map((b) => ({
512
+ id: b.sourceId,
513
+ type: b.source,
514
+ kind: b.source,
515
+ }));
516
+
517
+ const result = {
518
+ text: recallResult.text,
519
+ resultCount: recallResult.bullets.length,
520
+ degraded: false,
521
+ items,
522
+ sources: {
523
+ semantic: recallResult.prefetchHitCount,
524
+ recency: 0,
525
+ },
526
+ };
527
+
528
+ return {
529
+ content: JSON.stringify(result),
530
+ isError: false,
531
+ };
532
+ } catch (err) {
533
+ const msg = err instanceof Error ? err.message : String(err);
534
+ log.error({ err, query }, "simplified memory_recall failed");
535
+ return {
536
+ content: `Error: Memory recall failed: ${msg}`,
537
+ isError: true,
538
+ };
539
+ }
540
+ }
541
+
414
542
  // ── Helpers ──────────────────────────────────────────────────────────
415
543
 
416
544
  function inferSubjectFromStatement(statement: string): string {
@@ -41,6 +41,7 @@ export async function executeScheduleCreate(
41
41
  const routingHints = input.routing_hints as
42
42
  | Record<string, unknown>
43
43
  | undefined;
44
+ const quiet = (input.quiet as boolean) ?? false;
44
45
 
45
46
  if (!name || typeof name !== "string") {
46
47
  return {
@@ -112,6 +113,7 @@ export async function executeScheduleCreate(
112
113
  mode,
113
114
  routingIntent: routingIntent as RoutingIntent | undefined,
114
115
  routingHints,
116
+ quiet,
115
117
  });
116
118
 
117
119
  const fireDate = formatLocalDate(job.nextRunAt);
@@ -187,6 +189,7 @@ export async function executeScheduleCreate(
187
189
  mode,
188
190
  routingIntent: routingIntent as RoutingIntent | undefined,
189
191
  routingHints,
192
+ quiet,
190
193
  });
191
194
 
192
195
  const scheduleDescription =
@@ -62,7 +62,11 @@ export async function executeScheduleList(
62
62
  );
63
63
  }
64
64
 
65
- lines.push(` Enabled: ${job.enabled}`, ` Message: ${job.message}`);
65
+ lines.push(
66
+ ` Enabled: ${job.enabled}`,
67
+ ` Quiet: ${job.quiet}`,
68
+ ` Message: ${job.message}`,
69
+ );
66
70
 
67
71
  if (!oneShot) {
68
72
  lines.push(` Next run: ${formatLocalDate(job.nextRunAt)}`);
@@ -97,6 +97,11 @@ export async function executeScheduleUpdate(
97
97
  updates.routingHints = input.routing_hints;
98
98
  }
99
99
 
100
+ // Quiet mode
101
+ if (input.quiet !== undefined) {
102
+ updates.quiet = input.quiet;
103
+ }
104
+
100
105
  // Auto-detect syntax when expression changes without explicit syntax
101
106
  if (input.expression !== undefined || input.syntax !== undefined) {
102
107
  const resolved = normalizeScheduleSyntax({
@@ -159,6 +164,7 @@ export async function executeScheduleUpdate(
159
164
  mode?: ScheduleMode;
160
165
  routingIntent?: RoutingIntent;
161
166
  routingHints?: Record<string, unknown>;
167
+ quiet?: boolean;
162
168
  },
163
169
  );
164
170
 
@@ -6,8 +6,10 @@
6
6
  * extensible for future per-device metadata.
7
7
  *
8
8
  * Path resolution:
9
- * - Containerized (IS_CONTAINERIZED=true): uses BASE_DATA_DIR, which maps to a
10
- * persistent volume. Each container is effectively its own "device."
9
+ * - Containerized (IS_CONTAINERIZED=true): uses /home/assistant (the assistant
10
+ * user's persistent home dir) so device.json lives on the assistant's own
11
+ * filesystem rather than the shared data volume. Falls back to BASE_DATA_DIR
12
+ * for migration from the old location.
11
13
  * - Local (single or multi-instance): uses homedir() so all instances on the
12
14
  * same machine share a single device ID, even when BASE_DATA_DIR is set to
13
15
  * an instance-scoped directory.
@@ -31,18 +33,34 @@ let cached: string | undefined;
31
33
  /**
32
34
  * Resolve the base directory for device.json.
33
35
  *
34
- * In containerized environments, BASE_DATA_DIR points to a persistent volume
35
- * and homedir() is ephemeral, so we must use BASE_DATA_DIR.
36
+ * In containerized environments, device.json is stored under /home/assistant
37
+ * (the assistant user's persistent home dir) rather than on the shared data
38
+ * volume. Device ID is assistant-specific state that doesn't need to be shared.
36
39
  * In local environments (including multi-instance), homedir() is stable and
37
40
  * shared across instances, giving a true per-machine device ID.
38
41
  */
39
42
  export function getDeviceIdBaseDir(): string {
40
43
  if (getIsContainerized()) {
41
- return getBaseDataDir() || homedir();
44
+ return "/home/assistant";
42
45
  }
43
46
  return homedir();
44
47
  }
45
48
 
49
+ /**
50
+ * Resolve the legacy base directory for device.json migration.
51
+ *
52
+ * Returns the old containerized path (BASE_DATA_DIR) so we can fall back to
53
+ * reading device.json from the shared volume if it hasn't been migrated yet.
54
+ * Returns undefined when not containerized or when no legacy path exists.
55
+ */
56
+ function getLegacyDeviceIdBaseDir(): string | undefined {
57
+ if (!getIsContainerized()) {
58
+ return undefined;
59
+ }
60
+ const baseDataDir = getBaseDataDir();
61
+ return baseDataDir || undefined;
62
+ }
63
+
46
64
  /**
47
65
  * Get the stable device ID for this machine.
48
66
  *
@@ -78,10 +96,55 @@ export function getDeviceId(): string {
78
96
  }
79
97
  }
80
98
  } catch (err) {
81
- log.warn({ err }, "Failed to read device.json — generating new device ID");
99
+ log.warn({ err }, "Failed to read device.json — checking legacy path");
100
+ }
101
+
102
+ // Migration fallback: check the legacy location (shared volume) if the new
103
+ // location doesn't have a valid device.json yet.
104
+ const legacyBase = getLegacyDeviceIdBaseDir();
105
+ if (legacyBase) {
106
+ const legacyPath = join(legacyBase, ".vellum", "device.json");
107
+ try {
108
+ if (existsSync(legacyPath)) {
109
+ const raw = JSON.parse(readFileSync(legacyPath, "utf-8"));
110
+ if (
111
+ raw &&
112
+ typeof raw === "object" &&
113
+ typeof raw.deviceId === "string" &&
114
+ raw.deviceId.length > 0
115
+ ) {
116
+ cached = raw.deviceId as string;
117
+ log.info(
118
+ { deviceId: cached },
119
+ "Resolved device ID from legacy device.json — will persist to new location",
120
+ );
121
+ // Persist to the new location so future reads don't need the fallback
122
+ try {
123
+ mkdirSync(vellumDir, { recursive: true });
124
+ writeFileSync(
125
+ filePath,
126
+ JSON.stringify({ deviceId: cached }, null, 2) + "\n",
127
+ { mode: 0o644 },
128
+ );
129
+ log.info("Migrated device.json to new location");
130
+ } catch (writeErr) {
131
+ log.warn(
132
+ { err: writeErr },
133
+ "Failed to migrate device.json to new location",
134
+ );
135
+ }
136
+ return cached;
137
+ }
138
+ }
139
+ } catch (err) {
140
+ log.warn(
141
+ { err },
142
+ "Failed to read legacy device.json — generating new device ID",
143
+ );
144
+ }
82
145
  }
83
146
 
84
- // Either the file doesn't exist, or deviceId was missing/empty.
147
+ // Either the file doesn't exist at either location, or deviceId was missing/empty.
85
148
  // Generate a new UUID and persist it.
86
149
  try {
87
150
  mkdirSync(vellumDir, { recursive: true });
@@ -76,20 +76,44 @@ let rootLogger: pino.Logger | null = null;
76
76
  let activeLogDate: string | null = null;
77
77
  let activeLogFileConfig: LogFileConfig | null = null;
78
78
 
79
+ function resolveLogDir(config: LogFileConfig): string | undefined {
80
+ if (!config.dir) return undefined;
81
+
82
+ if (!existsSync(config.dir)) {
83
+ try {
84
+ mkdirSync(config.dir, { recursive: true });
85
+ } catch (err) {
86
+ if (getIsContainerized()) {
87
+ // Config has a host-specific path that can't be created inside the
88
+ // container (e.g. /Users/…). Fall back to the default log directory.
89
+ const fallback = join(getLogPath(), "..");
90
+ console.warn(
91
+ `[logger] Configured logFile.dir "${config.dir}" cannot be created ` +
92
+ `in container (${(err as Error).message}). Falling back to "${fallback}".`,
93
+ );
94
+ if (!existsSync(fallback)) {
95
+ mkdirSync(fallback, { recursive: true });
96
+ }
97
+ return fallback;
98
+ }
99
+ throw err;
100
+ }
101
+ }
102
+
103
+ return config.dir;
104
+ }
105
+
79
106
  function buildRotatingLogger(config: LogFileConfig): pino.Logger {
80
- if (!config.dir) {
107
+ const dir = resolveLogDir(config);
108
+ if (!dir) {
81
109
  return pino(
82
110
  { name: "assistant", serializers: logSerializers },
83
111
  pinoPretty(prettyOpts({ destination: 1 })),
84
112
  );
85
113
  }
86
114
 
87
- if (!existsSync(config.dir)) {
88
- mkdirSync(config.dir, { recursive: true });
89
- }
90
-
91
115
  const today = formatDate(new Date());
92
- const filePath = logFilePathForDate(config.dir, new Date());
116
+ const filePath = logFilePathForDate(dir, new Date());
93
117
  const fileDest = pino.destination({
94
118
  dest: filePath,
95
119
  sync: false,
@@ -107,7 +131,7 @@ function buildRotatingLogger(config: LogFileConfig): pino.Logger {
107
131
  );
108
132
 
109
133
  activeLogDate = today;
110
- activeLogFileConfig = config;
134
+ activeLogFileConfig = { ...config, dir };
111
135
 
112
136
  // When stdout is not a TTY (e.g. desktop app redirects to a hatch log file),
113
137
  // write to the rotating file only — the hatch log already captured early
@@ -144,8 +168,10 @@ function ensureCurrentDate(): void {
144
168
  export function initLogger(config: LogFileConfig): void {
145
169
  rootLogger = buildRotatingLogger(config);
146
170
 
147
- if (config.dir && config.retentionDays > 0) {
148
- const removed = pruneOldLogFiles(config.dir, config.retentionDays);
171
+ // Use the resolved dir (may differ from config.dir when containerized)
172
+ const resolvedDir = activeLogFileConfig?.dir;
173
+ if (resolvedDir && config.retentionDays > 0) {
174
+ const removed = pruneOldLogFiles(resolvedDir, config.retentionDays);
149
175
  if (removed > 0) {
150
176
  rootLogger.info(
151
177
  { removed, retentionDays: config.retentionDays },
@@ -8,7 +8,11 @@ import {
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
10
10
 
11
- import { getBaseDataDir } from "../config/env-registry.js";
11
+ import {
12
+ getBaseDataDir,
13
+ getIsContainerized,
14
+ getWorkspaceDirOverride,
15
+ } from "../config/env-registry.js";
12
16
 
13
17
  export function isMacOS(): boolean {
14
18
  return process.platform === "darwin";
@@ -237,6 +241,15 @@ export function getInterfacesDir(): string {
237
241
  return join(getDataDir(), "interfaces");
238
242
  }
239
243
 
244
+ /**
245
+ * Returns the sounds directory (~/.vellum/workspace/data/sounds).
246
+ * Custom sound files and sound configuration live here. Sound files
247
+ * can be large, so this directory is excluded from diagnostic exports.
248
+ */
249
+ export function getSoundsDir(): string {
250
+ return join(getWorkspaceDir(), "data", "sounds");
251
+ }
252
+
240
253
  /**
241
254
  * Returns the TCP port the daemon should listen on for iOS clients.
242
255
  * Hardcoded default: 8765.
@@ -356,13 +369,19 @@ export function getSignalsDir(): string {
356
369
  // Currently not used by call-sites; wired in later PRs.
357
370
 
358
371
  /**
359
- * Returns ~/.vellum/workspace — the workspace root for user-facing state.
372
+ * Returns the workspace root for user-facing state.
373
+ *
374
+ * When the WORKSPACE_DIR env var is set, returns that value (used in
375
+ * containerized deployments where the workspace is a separate volume).
376
+ * Otherwise falls back to ~/.vellum/workspace.
360
377
  *
361
378
  * WARNING: The entire workspace directory is included in diagnostic log exports
362
379
  * ("Send logs to Vellum"). Do not store secrets, API keys, or sensitive
363
380
  * credentials here — use the credential store or ~/.vellum/protected/ instead.
364
381
  */
365
382
  export function getWorkspaceDir(): string {
383
+ const override = getWorkspaceDirOverride();
384
+ if (override) return override;
366
385
  return join(getRootDir(), "workspace");
367
386
  }
368
387
 
@@ -413,13 +432,17 @@ export function ensureDataDir(): void {
413
432
  const root = getRootDir();
414
433
  const workspace = getWorkspaceDir();
415
434
  const wsData = join(workspace, "data");
435
+ const containerized = getIsContainerized();
416
436
  const dirs = [
417
- // Root-level dirs (runtime / protected)
437
+ // Root-level dirs (runtime)
418
438
  root,
419
- join(root, "protected"),
439
+ // protected, signals, hooks are local-only — skip in containerized mode
440
+ // (credentials via CES HTTP API, trust via gateway API, no IPC signals)
441
+ ...(containerized
442
+ ? []
443
+ : [join(root, "protected"), join(root, "signals"), join(root, "hooks")]),
420
444
  // Workspace dirs
421
445
  workspace,
422
- join(root, "hooks"),
423
446
  join(workspace, "skills"),
424
447
  join(workspace, "embedding-models"),
425
448
  join(workspace, "conversations"),
@@ -432,6 +455,7 @@ export function ensureDataDir(): void {
432
455
  join(wsData, "memory", "knowledge"),
433
456
  join(wsData, "apps"),
434
457
  join(wsData, "interfaces"),
458
+ join(wsData, "sounds"),
435
459
  ];
436
460
  for (const dir of dirs) {
437
461
  if (!existsSync(dir)) {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Workspace migration: Migrate workspace data from /data to /workspace volume.
3
+ *
4
+ * In the old Docker volume layout, workspace data lived at
5
+ * `$BASE_DATA_DIR/.vellum/workspace`. In the new layout, WORKSPACE_DIR points
6
+ * to a dedicated volume (e.g. `/workspace`). On first boot with the new layout,
7
+ * this migration copies existing workspace data from the old location to the
8
+ * new volume so nothing is lost.
9
+ *
10
+ * Idempotent:
11
+ * - Skips if WORKSPACE_DIR is not set (non-Docker or old layout).
12
+ * - Skips if the workspace volume already has data (config.json exists).
13
+ * - Skips if the sentinel file exists (already migrated).
14
+ * - Skips if the old workspace directory doesn't exist or is empty.
15
+ */
16
+
17
+ import { cpSync, existsSync, readdirSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ import {
21
+ getBaseDataDir,
22
+ getWorkspaceDirOverride,
23
+ } from "../../config/env-registry.js";
24
+ import type { WorkspaceMigration } from "./types.js";
25
+
26
+ const SENTINEL_FILENAME = ".workspace-volume-migrated";
27
+
28
+ export const migrateToWorkspaceVolumeMigration: WorkspaceMigration = {
29
+ id: "014-migrate-to-workspace-volume",
30
+ description:
31
+ "Copy workspace data from old /data/.vellum/workspace to new WORKSPACE_DIR volume on first boot",
32
+
33
+ run(workspaceDir: string): void {
34
+ const workspaceDirOverride = getWorkspaceDirOverride();
35
+
36
+ // Only relevant when WORKSPACE_DIR is explicitly set (Docker with separate volume)
37
+ if (!workspaceDirOverride) return;
38
+
39
+ const sentinelPath = join(workspaceDir, SENTINEL_FILENAME);
40
+
41
+ // Already migrated — skip
42
+ if (existsSync(sentinelPath)) return;
43
+
44
+ // If the workspace volume already has data (config.json), assume it's
45
+ // already populated — either by a previous migration or manual setup.
46
+ if (existsSync(join(workspaceDir, "config.json"))) {
47
+ // Write sentinel so we don't re-check on every boot
48
+ writeSentinel(sentinelPath);
49
+ return;
50
+ }
51
+
52
+ // Resolve the old workspace location: $BASE_DATA_DIR/.vellum/workspace
53
+ const baseDataDir = getBaseDataDir();
54
+ if (!baseDataDir) {
55
+ // No BASE_DATA_DIR means there's no old location to migrate from
56
+ writeSentinel(sentinelPath);
57
+ return;
58
+ }
59
+
60
+ const oldWorkspaceDir = join(baseDataDir, ".vellum", "workspace");
61
+
62
+ // If the old workspace doesn't exist or is empty, nothing to migrate
63
+ if (!existsSync(oldWorkspaceDir)) {
64
+ writeSentinel(sentinelPath);
65
+ return;
66
+ }
67
+
68
+ let entries: string[];
69
+ try {
70
+ entries = readdirSync(oldWorkspaceDir);
71
+ } catch {
72
+ // Can't read old workspace — write sentinel and move on
73
+ writeSentinel(sentinelPath);
74
+ return;
75
+ }
76
+
77
+ if (entries.length === 0) {
78
+ writeSentinel(sentinelPath);
79
+ return;
80
+ }
81
+
82
+ // Copy everything from old workspace to new workspace volume.
83
+ // Use cpSync with recursive to handle nested directories.
84
+ // Copy each entry individually rather than the whole directory to avoid
85
+ // overwriting the target directory itself (which may already have
86
+ // sub-directories created by ensureDataDir).
87
+ for (const entry of entries) {
88
+ const src = join(oldWorkspaceDir, entry);
89
+ const dst = join(workspaceDir, entry);
90
+
91
+ // Skip if destination already exists (partial previous run)
92
+ if (existsSync(dst)) continue;
93
+
94
+ try {
95
+ cpSync(src, dst, { recursive: true });
96
+ } catch {
97
+ // Best-effort per entry — continue with remaining items
98
+ }
99
+ }
100
+
101
+ // Mark migration complete
102
+ writeSentinel(sentinelPath);
103
+ },
104
+ };
105
+
106
+ function writeSentinel(sentinelPath: string): void {
107
+ try {
108
+ writeFileSync(sentinelPath, new Date().toISOString() + "\n", "utf-8");
109
+ } catch {
110
+ // Best-effort — if we can't write the sentinel, the migration runner's
111
+ // checkpoint will still prevent re-running the migration function.
112
+ }
113
+ }
@@ -10,6 +10,7 @@ import { appDirRenameMigration } from "./010-app-dir-rename.js";
10
10
  import { backfillInstallationIdMigration } from "./011-backfill-installation-id.js";
11
11
  import { renameConversationDiskViewDirsMigration } from "./012-rename-conversation-disk-view-dirs.js";
12
12
  import { repairConversationDiskViewMigration } from "./013-repair-conversation-disk-view.js";
13
+ import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js";
13
14
  import type { WorkspaceMigration } from "./types.js";
14
15
 
15
16
  /**
@@ -29,4 +30,5 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [
29
30
  appDirRenameMigration,
30
31
  renameConversationDiskViewDirsMigration,
31
32
  repairConversationDiskViewMigration,
33
+ migrateToWorkspaceVolumeMigration,
32
34
  ];