@vellumai/assistant 0.5.4 → 0.5.6

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 (151) hide show
  1. package/Dockerfile +17 -27
  2. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  3. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/actor-token-service.test.ts +113 -0
  6. package/src/__tests__/config-schema.test.ts +2 -2
  7. package/src/__tests__/context-window-manager.test.ts +78 -0
  8. package/src/__tests__/conversation-title-service.test.ts +30 -1
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  11. package/src/__tests__/memory-regressions.test.ts +8 -30
  12. package/src/__tests__/openai-whisper.test.ts +93 -0
  13. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  14. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  16. package/src/__tests__/tool-executor.test.ts +4 -0
  17. package/src/__tests__/volume-security-guard.test.ts +155 -0
  18. package/src/cli/commands/conversations.ts +0 -18
  19. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  20. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  21. package/src/config/env-registry.ts +9 -0
  22. package/src/config/env.ts +8 -2
  23. package/src/config/feature-flag-registry.json +8 -8
  24. package/src/config/schema.ts +0 -12
  25. package/src/config/schemas/memory.ts +0 -4
  26. package/src/config/schemas/platform.ts +1 -1
  27. package/src/config/schemas/security.ts +4 -0
  28. package/src/context/window-manager.ts +53 -2
  29. package/src/credential-execution/managed-catalog.ts +5 -15
  30. package/src/daemon/conversation-agent-loop.ts +0 -60
  31. package/src/daemon/conversation-memory.ts +0 -117
  32. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  33. package/src/daemon/daemon-control.ts +7 -0
  34. package/src/daemon/handlers/conversations.ts +0 -11
  35. package/src/daemon/lifecycle.ts +10 -47
  36. package/src/daemon/providers-setup.ts +2 -1
  37. package/src/followups/followup-store.ts +5 -2
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/conversation-crud.ts +0 -236
  41. package/src/memory/conversation-title-service.ts +26 -10
  42. package/src/memory/db-init.ts +5 -13
  43. package/src/memory/embedding-local.ts +11 -5
  44. package/src/memory/indexer.ts +15 -106
  45. package/src/memory/job-handlers/conversation-starters.ts +24 -36
  46. package/src/memory/job-handlers/embedding.ts +0 -79
  47. package/src/memory/job-utils.ts +1 -1
  48. package/src/memory/jobs-store.ts +0 -8
  49. package/src/memory/jobs-worker.ts +0 -20
  50. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  51. package/src/memory/migrations/index.ts +1 -3
  52. package/src/memory/qdrant-client.ts +4 -6
  53. package/src/memory/schema/conversations.ts +0 -3
  54. package/src/memory/schema/index.ts +0 -2
  55. package/src/messaging/draft-store.ts +2 -2
  56. package/src/messaging/provider.ts +9 -0
  57. package/src/messaging/providers/slack/adapter.ts +29 -2
  58. package/src/oauth/connection-resolver.test.ts +22 -18
  59. package/src/oauth/connection-resolver.ts +92 -7
  60. package/src/oauth/platform-connection.test.ts +78 -69
  61. package/src/oauth/platform-connection.ts +12 -19
  62. package/src/permissions/defaults.ts +3 -3
  63. package/src/permissions/trust-client.ts +332 -0
  64. package/src/permissions/trust-store-interface.ts +105 -0
  65. package/src/permissions/trust-store.ts +531 -39
  66. package/src/platform/client.test.ts +148 -0
  67. package/src/platform/client.ts +71 -0
  68. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  69. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  70. package/src/providers/speech-to-text/resolve.ts +9 -0
  71. package/src/providers/speech-to-text/types.ts +17 -0
  72. package/src/runtime/auth/route-policy.ts +14 -0
  73. package/src/runtime/auth/token-service.ts +133 -0
  74. package/src/runtime/http-server.ts +4 -2
  75. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  76. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  77. package/src/runtime/routes/conversation-routes.ts +2 -1
  78. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  79. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  80. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  81. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  82. package/src/runtime/routes/log-export-routes.ts +1 -0
  83. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  84. package/src/runtime/routes/memory-item-routes.ts +124 -2
  85. package/src/runtime/routes/secret-routes.ts +4 -1
  86. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  87. package/src/schedule/schedule-store.ts +0 -21
  88. package/src/security/ces-credential-client.ts +173 -0
  89. package/src/security/secure-keys.ts +65 -22
  90. package/src/signals/bash.ts +3 -0
  91. package/src/signals/cancel.ts +3 -0
  92. package/src/signals/confirm.ts +3 -0
  93. package/src/signals/conversation-undo.ts +3 -0
  94. package/src/signals/event-stream.ts +7 -0
  95. package/src/signals/shotgun.ts +3 -0
  96. package/src/signals/trust-rule.ts +3 -0
  97. package/src/skills/inline-command-render.ts +5 -1
  98. package/src/skills/inline-command-runner.ts +30 -2
  99. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  100. package/src/telemetry/usage-telemetry-reporter.ts +21 -19
  101. package/src/tools/memory/handlers.ts +1 -129
  102. package/src/tools/permission-checker.ts +18 -0
  103. package/src/tools/skills/load.ts +9 -2
  104. package/src/util/device-id.ts +70 -7
  105. package/src/util/logger.ts +35 -9
  106. package/src/util/platform.ts +29 -5
  107. package/src/util/xml.ts +8 -0
  108. package/src/workspace/heartbeat-service.ts +5 -24
  109. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  110. package/src/workspace/migrations/registry.ts +2 -0
  111. package/src/__tests__/archive-recall.test.ts +0 -560
  112. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  113. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  114. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  115. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  116. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  117. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  118. package/src/__tests__/memory-brief-time.test.ts +0 -285
  119. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  120. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  121. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  122. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  123. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  124. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  125. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  126. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  127. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  128. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  129. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  130. package/src/__tests__/memory-reducer.test.ts +0 -704
  131. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  132. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  133. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  134. package/src/config/schemas/memory-simplified.ts +0 -101
  135. package/src/memory/archive-recall.ts +0 -516
  136. package/src/memory/archive-store.ts +0 -400
  137. package/src/memory/brief-formatting.ts +0 -33
  138. package/src/memory/brief-open-loops.ts +0 -266
  139. package/src/memory/brief-time.ts +0 -162
  140. package/src/memory/brief.ts +0 -75
  141. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  142. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  143. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  144. package/src/memory/migrations/186-memory-archive.ts +0 -109
  145. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  146. package/src/memory/reducer-scheduler.ts +0 -242
  147. package/src/memory/reducer-store.ts +0 -271
  148. package/src/memory/reducer-types.ts +0 -106
  149. package/src/memory/reducer.ts +0 -467
  150. package/src/memory/schema/memory-archive.ts +0 -121
  151. package/src/memory/schema/memory-brief.ts +0 -55
@@ -22,7 +22,7 @@ import {
22
22
  import { queryUnreportedLifecycleEvents } from "../memory/lifecycle-events-store.js";
23
23
  import { queryUnreportedUsageEvents } from "../memory/llm-usage-store.js";
24
24
  import { queryUnreportedTurnEvents } from "../memory/turn-events-store.js";
25
- import { resolveManagedProxyContext } from "../providers/managed-proxy/context.js";
25
+ import { VellumPlatformClient } from "../platform/client.js";
26
26
  import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
27
27
  import { getExternalAssistantId } from "../runtime/auth/external-assistant-id.js";
28
28
  import { getDeviceId } from "../util/device-id.js";
@@ -139,22 +139,11 @@ export class UsageTelemetryReporter {
139
139
  return;
140
140
 
141
141
  // Resolve auth context — skip flush when neither auth mode is viable
142
- const proxyCtx = await resolveManagedProxyContext();
143
- if (!proxyCtx.enabled && !getTelemetryAppToken()) {
142
+ const client = await VellumPlatformClient.create();
143
+ if (!client && !getTelemetryAppToken()) {
144
144
  return;
145
145
  }
146
146
 
147
- let url: string;
148
- let authHeaders: Record<string, string>;
149
-
150
- if (proxyCtx.enabled) {
151
- url = `${proxyCtx.platformBaseUrl}${TELEMETRY_PATH}`;
152
- authHeaders = { Authorization: `Api-Key ${proxyCtx.assistantApiKey}` };
153
- } else {
154
- url = `${getTelemetryPlatformUrl()}${TELEMETRY_PATH}`;
155
- authHeaders = { "X-Telemetry-Token": getTelemetryAppToken() };
156
- }
157
-
158
147
  // Build payload
159
148
  const typedEvents: TelemetryEvent[] = [
160
149
  ...events.map(
@@ -195,26 +184,39 @@ export class UsageTelemetryReporter {
195
184
  const payload = {
196
185
  device_id: getDeviceId(),
197
186
  assistant_id: assistantId,
198
- app_version: APP_VERSION,
187
+ assistant_version: APP_VERSION,
199
188
  ...(organizationId ? { organization_id: organizationId } : {}),
200
189
  ...(userId ? { user_id: userId } : {}),
201
190
  events: typedEvents,
202
191
  };
203
192
 
204
193
  // Send
205
- const resp = await fetch(url, {
194
+ const fetchInit: RequestInit = {
206
195
  method: "POST",
207
196
  headers: {
208
197
  "Content-Type": "application/json",
209
- ...authHeaders,
210
198
  },
211
199
  body: JSON.stringify(payload),
212
- });
200
+ };
201
+
202
+ let resp: Response;
203
+ if (client) {
204
+ resp = await client.fetch(TELEMETRY_PATH, fetchInit);
205
+ } else {
206
+ const url = `${getTelemetryPlatformUrl()}${TELEMETRY_PATH}`;
207
+ resp = await fetch(url, {
208
+ ...fetchInit,
209
+ headers: {
210
+ "Content-Type": "application/json",
211
+ "X-Telemetry-Token": getTelemetryAppToken(),
212
+ },
213
+ });
214
+ }
213
215
 
214
216
  if (!resp.ok) {
215
217
  await resp.text(); // consume body to release connection
216
218
  log.warn(
217
- { status: resp.status, url },
219
+ { status: resp.status },
218
220
  "Usage telemetry POST failed — will retry next cycle",
219
221
  );
220
222
  return;
@@ -2,8 +2,6 @@ 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";
7
5
  import { getDb } from "../../memory/db.js";
8
6
  import { computeMemoryFingerprint } from "../../memory/fingerprint.js";
9
7
  import { enqueueMemoryJob } from "../../memory/jobs-store.js";
@@ -20,7 +18,7 @@ const log = getLogger("memory-tools");
20
18
 
21
19
  export async function handleMemorySave(
22
20
  args: Record<string, unknown>,
23
- config: AssistantConfig,
21
+ _config: AssistantConfig,
24
22
  conversationId: string,
25
23
  messageId: string | undefined,
26
24
  scopeId: string = "default",
@@ -65,19 +63,6 @@ export async function handleMemorySave(
65
63
  ? truncate(args.subject.trim(), 80, "")
66
64
  : inferSubjectFromStatement(statement.trim());
67
65
 
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
-
81
66
  try {
82
67
  const db = getDb();
83
68
  const id = uuid();
@@ -290,12 +275,6 @@ export async function handleMemoryRecall(
290
275
  ? args.scope.trim()
291
276
  : "default";
292
277
 
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
-
299
278
  // Scope policy: "conversation" means strict (only that scope),
300
279
  // anything else allows fallback to the default scope.
301
280
  const scopePolicyOverride: ScopePolicyOverride | undefined = scopeId
@@ -432,113 +411,6 @@ export async function handleMemoryDelete(
432
411
  }
433
412
  }
434
413
 
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
-
542
414
  // ── Helpers ──────────────────────────────────────────────────────────
543
415
 
544
416
  function inferSubjectFromStatement(statement: string): string {
@@ -137,6 +137,24 @@ export class PermissionChecker {
137
137
  }
138
138
 
139
139
  if (result.decision === "prompt") {
140
+ // dangerouslySkipPermissions: when enabled, auto-approve all prompts
141
+ // without user interaction. Deny rules are still respected (they
142
+ // return before reaching this block).
143
+ //
144
+ // Note: unlike guardian auto-approve and temporary overrides below,
145
+ // this intentionally does NOT check `context.requireFreshApproval`.
146
+ // The setting is designed to skip ALL interactive prompts
147
+ // unconditionally — it is an explicit operator opt-out from the
148
+ // permission system, so requireFreshApproval does not apply.
149
+ const cfg = getConfig();
150
+ if (cfg.permissions.dangerouslySkipPermissions) {
151
+ log.info(
152
+ { toolName: name, riskLevel },
153
+ "dangerouslySkipPermissions active — auto-approving without prompt",
154
+ );
155
+ return { allowed: true, decision: "dangerously_skip_permissions", riskLevel };
156
+ }
157
+
140
158
  // Guardian-trust sessions (e.g. scheduled jobs, reminders) should be
141
159
  // able to use bundled tools without interactive approval. The guardian
142
160
  // is the owner - prompting makes no sense when there is no client.
@@ -440,9 +440,16 @@ export class SkillLoadTool implements Tool {
440
440
  "Rendered inline command expansions for included skill",
441
441
  );
442
442
  } catch (err) {
443
- log.warn(
443
+ log.error(
444
444
  { err, skillId: childId, parentSkillId: skill.id },
445
- "Failed to render inline commands for included skill, using raw body",
445
+ "Failed to render inline commands for included skill; falling back to sanitized body",
446
+ );
447
+ // Strip raw !`...` inline command tokens so they don't leak into
448
+ // the prompt. Replace with a safe stub to maintain fail-closed
449
+ // contract for raw tokens while still isolating child failures.
450
+ childBody = childBody.replace(
451
+ /!`[^`]*`/g,
452
+ "[inline command unavailable]",
446
453
  );
447
454
  }
448
455
  }
@@ -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
+ // signals dir is needed everywhere (MCP reload, user-message signals)
440
+ join(root, "signals"),
441
+ // protected, hooks are local-only — skip in containerized mode
442
+ // (credentials via CES HTTP API, trust via gateway API)
443
+ ...(containerized ? [] : [join(root, "protected"), 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)) {
package/src/util/xml.ts CHANGED
@@ -6,3 +6,11 @@ export function escapeXmlAttr(s: string): string {
6
6
  .replace(/</g, "&lt;")
7
7
  .replace(/>/g, "&gt;");
8
8
  }
9
+
10
+ /** Escape a string for safe inclusion as XML/HTML text content. */
11
+ export function escapeXmlContent(s: string): string {
12
+ return s
13
+ .replace(/&/g, "&amp;")
14
+ .replace(/</g, "&lt;")
15
+ .replace(/>/g, "&gt;");
16
+ }
@@ -210,13 +210,11 @@ export class WorkspaceHeartbeatService {
210
210
 
211
211
  try {
212
212
  const now = this.now();
213
- let shutdownFiles: string[] = [];
214
213
  const { committed } = await service.commitIfDirty(
215
214
  (st) => {
216
215
  const uniqueFiles = [
217
216
  ...new Set([...st.staged, ...st.modified, ...st.untracked]),
218
217
  ];
219
- shutdownFiles = uniqueFiles;
220
218
  log.info(
221
219
  { workspaceDir, totalChanges: uniqueFiles.length },
222
220
  "Committing pending changes on shutdown",
@@ -237,28 +235,11 @@ export class WorkspaceHeartbeatService {
237
235
  if (committed) {
238
236
  firstSeenDirty.delete(workspaceDir);
239
237
  result.committed++;
240
-
241
- // Fire-and-forget enrichment
242
- try {
243
- const commitHash = await service.getHeadHash();
244
- const shutdownCtx: CommitContext = {
245
- workspaceDir,
246
- trigger: "shutdown",
247
- changedFiles: shutdownFiles,
248
- timestampMs: this.now(),
249
- };
250
- getEnrichmentService().enqueue({
251
- workspaceDir,
252
- commitHash,
253
- context: shutdownCtx,
254
- gitService: service,
255
- });
256
- } catch (enrichErr) {
257
- log.debug(
258
- { enrichErr },
259
- "Failed to enqueue shutdown enrichment (non-fatal)",
260
- );
261
- }
238
+ // Skip enrichment for shutdown commits — the enrichment queue is
239
+ // about to be shut down anyway, and the fire-and-forget writeNote()
240
+ // can race with subsequent commitAllPending() calls (the async
241
+ // git-notes operation acquires the mutex and may leave behind an
242
+ // index.lock on some git versions, causing the next commit to fail).
262
243
  } else {
263
244
  result.skipped++;
264
245
  }