@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
@@ -11,8 +11,7 @@
11
11
 
12
12
  import { platformOAuthHandle } from "@vellumai/ces-contracts";
13
13
 
14
- import { getPlatformAssistantId } from "../config/env.js";
15
- import { resolveManagedProxyContext } from "../providers/managed-proxy/context.js";
14
+ import { VellumPlatformClient } from "../platform/client.js";
16
15
  import { getLogger } from "../util/logger.js";
17
16
 
18
17
  const log = getLogger("managed-catalog");
@@ -79,25 +78,18 @@ export interface FetchManagedCatalogResult {
79
78
  * error message that never contains secret material.
80
79
  */
81
80
  export async function fetchManagedCatalog(): Promise<FetchManagedCatalogResult> {
82
- const ctx = await resolveManagedProxyContext();
81
+ const client = await VellumPlatformClient.create();
83
82
 
84
- if (!ctx.enabled) {
83
+ if (!client || !client.platformAssistantId) {
85
84
  return { ok: true, descriptors: [] };
86
85
  }
87
86
 
88
- const assistantId = getPlatformAssistantId();
89
- if (!assistantId) {
90
- log.warn("PLATFORM_ASSISTANT_ID not set; cannot fetch managed catalog");
91
- return { ok: true, descriptors: [] };
92
- }
93
-
94
- const url = `${ctx.platformBaseUrl}/v1/assistants/${encodeURIComponent(assistantId)}/oauth/managed/catalog/`;
87
+ const path = `/v1/assistants/${encodeURIComponent(client.platformAssistantId)}/oauth/managed/catalog/`;
95
88
 
96
89
  try {
97
- const response = await fetch(url, {
90
+ const response = await client.fetch(path, {
98
91
  method: "GET",
99
92
  headers: {
100
- Authorization: `Api-Key ${ctx.assistantApiKey}`,
101
93
  Accept: "application/json",
102
94
  },
103
95
  });
@@ -139,8 +131,6 @@ export async function fetchManagedCatalog(): Promise<FetchManagedCatalogResult>
139
131
  return { ok: true, descriptors };
140
132
  } catch (err) {
141
133
  const message = err instanceof Error ? err.message : String(err);
142
- // Ensure the error message does not leak secrets — strip any URL params
143
- // that might contain tokens (defensive, since we use Api-Key header).
144
134
  const safeMessage = message.replace(
145
135
  /Api-Key\s+\S+/gi,
146
136
  "Api-Key [REDACTED]",
@@ -12,6 +12,7 @@ import {
12
12
  } from "node:fs";
13
13
  import { join } from "node:path";
14
14
 
15
+ import { getIsContainerized } from "../config/env-registry.js";
15
16
  import { getConfig, invalidateConfigCache } from "../config/loader.js";
16
17
  import { clearEmbeddingBackendCache } from "../memory/embedding-backend.js";
17
18
  import { clearCache as clearTrustCache } from "../permissions/trust-store.js";
@@ -209,7 +210,9 @@ export class ConfigWatcher {
209
210
  );
210
211
  }
211
212
 
212
- this.startSignalsWatcher();
213
+ if (!getIsContainerized()) {
214
+ this.startSignalsWatcher();
215
+ }
213
216
  this.startSkillsWatchers(onConversationEvict);
214
217
  }
215
218
 
@@ -1,5 +1,8 @@
1
1
  import { getConfig } from "../config/loader.js";
2
2
  import { estimatePromptTokens } from "../context/token-estimator.js";
3
+ import { buildArchiveRecall } from "../memory/archive-recall.js";
4
+ import { compileMemoryBrief } from "../memory/brief.js";
5
+ import { getDb } from "../memory/db.js";
3
6
  import { buildMemoryQuery } from "../memory/query-builder.js";
4
7
  import { computeRecallBudget } from "../memory/retrieval-budget.js";
5
8
  import {
@@ -9,8 +12,11 @@ import {
9
12
  import type { ScopePolicyOverride } from "../memory/search/types.js";
10
13
  import type { Message } from "../providers/types.js";
11
14
  import type { Provider } from "../providers/types.js";
15
+ import { getLogger } from "../util/logger.js";
12
16
  import type { ServerMessage } from "./message-protocol.js";
13
17
 
18
+ const log = getLogger("conversation-memory");
19
+
14
20
  export interface MemoryRecallResult {
15
21
  runMessages: Message[];
16
22
  recall: Awaited<ReturnType<typeof buildMemoryRecall>>;
@@ -115,6 +121,14 @@ export async function prepareMemoryContext(
115
121
 
116
122
  const runtimeConfig = getConfig();
117
123
 
124
+ // ── Simplified memory path ──────────────────────────────────────────
125
+ // When `memory.simplified.enabled` is true, inject the brief and
126
+ // optional archive recall instead of the legacy hybrid pipeline.
127
+ if (runtimeConfig.memory?.simplified?.enabled) {
128
+ return prepareSimplifiedMemoryContext(ctx, content, userMessageId, onEvent);
129
+ }
130
+
131
+ // ── Legacy memory path (fallback) ──────────────────────────────────
118
132
  // Memory recall via the V2 hybrid pipeline
119
133
  const recallQuery = buildMemoryQuery(content, ctx.messages);
120
134
  const dynamicBudgetConfig = runtimeConfig.memory?.retrieval?.dynamicBudget;
@@ -207,3 +221,106 @@ export async function prepareMemoryContext(
207
221
  recall,
208
222
  };
209
223
  }
224
+
225
+ // ── Simplified memory injection ─────────────────────────────────────────
226
+
227
+ /**
228
+ * Build simplified memory context for a turn: compiles the `<memory_brief>`
229
+ * block and conditionally appends `<supporting_recall>` from the archive.
230
+ *
231
+ * Non-empty blocks are injected as text content blocks prepended to the
232
+ * last user message, following the same injection pattern as the legacy
233
+ * pipeline. Stripping is handled by `RUNTIME_INJECTION_PREFIXES` which
234
+ * already includes `<memory_brief>`.
235
+ */
236
+ function prepareSimplifiedMemoryContext(
237
+ ctx: MemoryPrepareContext,
238
+ content: string,
239
+ userMessageId: string,
240
+ onEvent: (msg: ServerMessage) => void,
241
+ ): MemoryRecallResult {
242
+ const start = Date.now();
243
+
244
+ // Build a no-op recall result matching the legacy shape.
245
+ const noopRecall = (): Awaited<ReturnType<typeof buildMemoryRecall>> =>
246
+ ({
247
+ enabled: true,
248
+ degraded: false,
249
+ injectedText: "",
250
+ semanticHits: 0,
251
+ recencyHits: 0,
252
+ mergedCount: 0,
253
+ selectedCount: 0,
254
+ injectedTokens: 0,
255
+ latencyMs: 0,
256
+ topCandidates: [],
257
+ tier1Count: 0,
258
+ tier2Count: 0,
259
+ }) as Awaited<ReturnType<typeof buildMemoryRecall>>;
260
+
261
+ try {
262
+ const db = getDb();
263
+
264
+ // Step 1: Build the memory brief
265
+ const briefResult = compileMemoryBrief(db, ctx.scopeId, userMessageId);
266
+
267
+ // Step 2: Conditionally build supporting recall from the archive
268
+ const archiveResult = buildArchiveRecall(ctx.scopeId, content);
269
+
270
+ // Step 3: Assemble the injection blocks (non-empty only)
271
+ const blocks: string[] = [];
272
+ if (briefResult.text.length > 0) {
273
+ blocks.push(briefResult.text);
274
+ }
275
+ if (archiveResult.text.length > 0) {
276
+ blocks.push(archiveResult.text);
277
+ }
278
+
279
+ const latencyMs = Date.now() - start;
280
+
281
+ // Emit memory status for the simplified path
282
+ onEvent({
283
+ type: "memory_status",
284
+ enabled: true,
285
+ degraded: false,
286
+ });
287
+
288
+ // Inject non-empty blocks into the last user message
289
+ let runMessages = ctx.messages;
290
+ if (blocks.length > 0) {
291
+ const injectedText = blocks.join("\n\n");
292
+ const userTail = ctx.messages[ctx.messages.length - 1];
293
+ if (userTail && userTail.role === "user") {
294
+ runMessages = injectMemoryRecallAsUserBlock(ctx.messages, injectedText);
295
+ }
296
+
297
+ log.debug(
298
+ {
299
+ briefLength: briefResult.text.length,
300
+ recallTrigger: archiveResult.trigger,
301
+ recallBullets: archiveResult.bullets.length,
302
+ latencyMs,
303
+ },
304
+ "Simplified memory injection completed",
305
+ );
306
+ }
307
+
308
+ return {
309
+ runMessages,
310
+ recall: {
311
+ ...noopRecall(),
312
+ injectedText: blocks.length > 0 ? blocks.join("\n\n") : "",
313
+ latencyMs,
314
+ },
315
+ };
316
+ } catch (err) {
317
+ log.warn({ err }, "Simplified memory injection failed, returning no-op");
318
+ return {
319
+ runMessages: ctx.messages,
320
+ recall: {
321
+ ...noopRecall(),
322
+ latencyMs: Date.now() - start,
323
+ },
324
+ };
325
+ }
326
+ }
@@ -962,6 +962,7 @@ const RUNTIME_INJECTION_PREFIXES = [
962
962
  "<interface_turn_context>",
963
963
  "<turn_context>",
964
964
  "<memory_brief>",
965
+ "<supporting_recall>",
965
966
  "<memory_context __injected>",
966
967
  "<memory_context>", // backward-compat: strip legacy blocks from pre-__injected history
967
968
  "<voice_call_control>",
@@ -11,6 +11,7 @@ import {
11
11
  import { join, resolve } from "node:path";
12
12
 
13
13
  import { getRuntimeHttpHost, getRuntimeHttpPort } from "../config/env.js";
14
+ import { getIsContainerized } from "../config/env-registry.js";
14
15
  import { DaemonError } from "../util/errors.js";
15
16
  import { getLogger } from "../util/logger.js";
16
17
  import {
@@ -157,6 +158,7 @@ export async function isHttpHealthy(): Promise<boolean> {
157
158
  }
158
159
 
159
160
  function readPid(): number | null {
161
+ if (getIsContainerized()) return null; // Docker manages process lifecycle
160
162
  const pidPath = getPidPath();
161
163
  if (!existsSync(pidPath)) return null;
162
164
  try {
@@ -168,10 +170,12 @@ function readPid(): number | null {
168
170
  }
169
171
 
170
172
  export function writePid(pid: number): void {
173
+ if (getIsContainerized()) return; // Docker manages process lifecycle
171
174
  writeFileSync(getPidPath(), String(pid));
172
175
  }
173
176
 
174
177
  export function cleanupPidFile(): void {
178
+ if (getIsContainerized()) return; // Docker manages process lifecycle
175
179
  const pidPath = getPidPath();
176
180
  if (existsSync(pidPath)) {
177
181
  unlinkSync(pidPath);
@@ -181,6 +185,7 @@ export function cleanupPidFile(): void {
181
185
  /** Only remove the PID file if it belongs to the given process. Prevents a
182
186
  * failing second startup from deleting the PID of an already-running daemon. */
183
187
  export function cleanupPidFileIfOwner(ownerPid: number): void {
188
+ if (getIsContainerized()) return; // Docker manages process lifecycle
184
189
  const currentPid = readPid();
185
190
  if (currentPid === ownerPid) {
186
191
  cleanupPidFile();
@@ -188,6 +193,7 @@ export function cleanupPidFileIfOwner(ownerPid: number): void {
188
193
  }
189
194
 
190
195
  export function isDaemonRunning(): boolean {
196
+ if (getIsContainerized()) return true; // Container orchestrator manages lifecycle
191
197
  const pid = readPid();
192
198
  if (pid == null) return false;
193
199
  if (!isProcessRunning(pid)) {
@@ -201,6 +207,7 @@ export async function getDaemonStatus(): Promise<{
201
207
  running: boolean;
202
208
  pid?: number;
203
209
  }> {
210
+ if (getIsContainerized()) return { running: true, pid: process.pid }; // Container orchestrator manages lifecycle
204
211
  const pid = readPid();
205
212
  if (pid == null) return { running: false };
206
213
  if (!isProcessRunning(pid)) {
@@ -23,6 +23,7 @@ import {
23
23
  queueGenerateConversationTitle,
24
24
  UNTITLED_FALLBACK,
25
25
  } from "../../memory/conversation-title-service.js";
26
+ import { reduceBeforeSwitch } from "../../memory/reducer-scheduler.js";
26
27
  import * as pendingInteractions from "../../runtime/pending-interactions.js";
27
28
  import { getSubagentManager } from "../../subagent/index.js";
28
29
  import { truncate } from "../../util/truncate.js";
@@ -233,6 +234,12 @@ export async function handleConversationCreate(
233
234
  conversationType: normalizeConversationType(conversation.conversationType),
234
235
  });
235
236
 
237
+ // Reduce the previous dirty conversation before processing the initial
238
+ // message so its memory is fresh for the next read.
239
+ if (msg.initialMessage) {
240
+ await reduceBeforeSwitch(conversation.id);
241
+ }
242
+
236
243
  // Auto-send the initial message if provided, kick-starting the skill.
237
244
  if (msg.initialMessage) {
238
245
  // Queue title generation eagerly — some processMessage paths (guardian
@@ -343,6 +350,10 @@ export async function switchConversation(
343
350
  return null;
344
351
  }
345
352
 
353
+ // Reduce the previous dirty conversation before switching so its memory
354
+ // is fresh for the next read.
355
+ await reduceBeforeSwitch(conversationId);
356
+
346
357
  // If the target conversation is headless-locked (actively executing a task run),
347
358
  // skip rebinding so tool confirmations stay suppressed.
348
359
  const existingConversation = ctx.conversations.get(conversationId);
@@ -20,7 +20,7 @@ import { loadConfig } from "../config/loader.js";
20
20
  import { HeartbeatService } from "../heartbeat/heartbeat-service.js";
21
21
  import { getHookManager } from "../hooks/manager.js";
22
22
  import { installTemplates } from "../hooks/templates.js";
23
- import { closeSentry, initSentry } from "../instrument.js";
23
+ import { closeSentry, initSentry, setSentryDeviceId } from "../instrument.js";
24
24
  import { disableLogfire, initLogfire } from "../logfire.js";
25
25
  import { getMcpServerManager } from "../mcp/manager.js";
26
26
  import * as attachmentsStore from "../memory/attachments-store.js";
@@ -30,6 +30,7 @@ import {
30
30
  getConversationType,
31
31
  getMessages,
32
32
  purgePrivateConversations,
33
+ sweepStaleReducerJobs,
33
34
  } from "../memory/conversation-crud.js";
34
35
  import { resolveConversationId } from "../memory/conversation-key-store.js";
35
36
  import { initializeDb } from "../memory/db.js";
@@ -64,6 +65,7 @@ import { RuntimeHttpServer } from "../runtime/http-server.js";
64
65
  import { startScheduler } from "../schedule/scheduler.js";
65
66
  import { seedCatalogSkillMemories } from "../skills/skill-memory.js";
66
67
  import { UsageTelemetryReporter } from "../telemetry/usage-telemetry-reporter.js";
68
+ import { getDeviceId } from "../util/device-id.js";
67
69
  import { getLogger, initLogger } from "../util/logger.js";
68
70
  import {
69
71
  ensureDataDir,
@@ -178,6 +180,11 @@ export async function runDaemon(): Promise<void> {
178
180
  await runWorkspaceMigrations(getWorkspaceDir(), WORKSPACE_MIGRATIONS);
179
181
  log.info("Daemon startup: workspace migrations complete");
180
182
 
183
+ // Now that workspace migrations have run (including 003-seed-device-id
184
+ // which may copy the legacy installationId into device.json), it is safe
185
+ // to read the device ID and set the Sentry tag.
186
+ setSentryDeviceId(getDeviceId());
187
+
181
188
  // Purge private (temporary) conversations from the previous daemon run.
182
189
  // These are ephemeral by design and should not survive daemon restarts.
183
190
  const { count: purgedCount, deletedMemory } = purgePrivateConversations();
@@ -206,16 +213,40 @@ export async function runDaemon(): Promise<void> {
206
213
  targetId: summaryId,
207
214
  });
208
215
  }
216
+ for (const obsId of deletedMemory.deletedObservationIds) {
217
+ enqueueMemoryJob("delete_qdrant_vectors", {
218
+ targetType: "observation",
219
+ targetId: obsId,
220
+ });
221
+ }
222
+ for (const chunkId of deletedMemory.deletedChunkIds) {
223
+ enqueueMemoryJob("delete_qdrant_vectors", {
224
+ targetType: "chunk",
225
+ targetId: chunkId,
226
+ });
227
+ }
228
+ for (const episodeId of deletedMemory.deletedEpisodeIds) {
229
+ enqueueMemoryJob("delete_qdrant_vectors", {
230
+ targetType: "episode",
231
+ targetId: episodeId,
232
+ });
233
+ }
209
234
  if (
210
235
  deletedMemory.segmentIds.length > 0 ||
211
236
  deletedMemory.orphanedItemIds.length > 0 ||
212
- deletedMemory.deletedSummaryIds.length > 0
237
+ deletedMemory.deletedSummaryIds.length > 0 ||
238
+ deletedMemory.deletedObservationIds.length > 0 ||
239
+ deletedMemory.deletedChunkIds.length > 0 ||
240
+ deletedMemory.deletedEpisodeIds.length > 0
213
241
  ) {
214
242
  log.info(
215
243
  {
216
244
  segments: deletedMemory.segmentIds.length,
217
245
  orphanedItems: deletedMemory.orphanedItemIds.length,
218
246
  deletedSummaries: deletedMemory.deletedSummaryIds.length,
247
+ deletedObservations: deletedMemory.deletedObservationIds.length,
248
+ deletedChunks: deletedMemory.deletedChunkIds.length,
249
+ deletedEpisodes: deletedMemory.deletedEpisodeIds.length,
219
250
  },
220
251
  "Enqueued Qdrant vector cleanup jobs for purged private conversations",
221
252
  );
@@ -240,6 +271,24 @@ export async function runDaemon(): Promise<void> {
240
271
  );
241
272
  }
242
273
 
274
+ // Sweep dirty conversations whose tail messages are already past the
275
+ // idle delay — they should have been reduced while the daemon was down.
276
+ // Enqueue immediate reducer jobs so the memory worker picks them up.
277
+ try {
278
+ const sweepCount = sweepStaleReducerJobs();
279
+ if (sweepCount > 0) {
280
+ log.info(
281
+ { sweepCount },
282
+ `Enqueued reducer jobs for ${sweepCount} stale dirty conversation(s)`,
283
+ );
284
+ }
285
+ } catch (err) {
286
+ log.warn(
287
+ { err },
288
+ "Startup sweep for stale reducer jobs failed — continuing startup",
289
+ );
290
+ }
291
+
243
292
  // Ensure a vellum guardian binding exists so the identity system works
244
293
  // without requiring a manual bootstrap step.
245
294
  try {
@@ -5,7 +5,7 @@ import {
5
5
  setPlatformUserId,
6
6
  } from "../config/env.js";
7
7
  import type { AssistantConfig } from "../config/types.js";
8
- import { setSentryOrganizationId } from "../instrument.js";
8
+ import { setSentryOrganizationId, setSentryUserId } from "../instrument.js";
9
9
  import { getMcpServerManager } from "../mcp/manager.js";
10
10
  import { gmailMessagingProvider } from "../messaging/providers/gmail/adapter.js";
11
11
  import { slackProvider as slackMessagingProvider } from "../messaging/providers/slack/adapter.js";
@@ -91,6 +91,7 @@ export async function initializeProvidersAndTools(
91
91
  const trimmed = persisted?.trim();
92
92
  if (trimmed) {
93
93
  setPlatformUserId(trimmed);
94
+ setSentryUserId(trimmed);
94
95
  log.info("Rehydrated platform user ID from credential store");
95
96
  }
96
97
  } catch (err) {
@@ -1,5 +1,6 @@
1
1
  import { type FSWatcher, watch } from "node:fs";
2
2
 
3
+ import { getIsContainerized } from "../config/env-registry.js";
3
4
  import { Debouncer } from "../util/debounce.js";
4
5
  import { pathExists } from "../util/fs.js";
5
6
  import { getLogger } from "../util/logger.js";
@@ -32,6 +33,10 @@ export class HookManager {
32
33
  private readonly debouncer = new Debouncer(500);
33
34
 
34
35
  initialize(): void {
36
+ if (getIsContainerized()) {
37
+ log.info("Hooks disabled in containerized mode");
38
+ return;
39
+ }
35
40
  this.hooks = discoverHooks();
36
41
  this.buildEventIndex();
37
42
  const enabled = this.hooks.filter((h) => h.enabled).length;
@@ -107,6 +112,7 @@ export class HookManager {
107
112
  }
108
113
 
109
114
  reload(): void {
115
+ if (getIsContainerized()) return;
110
116
  this.hooks = discoverHooks();
111
117
  this.buildEventIndex();
112
118
  const enabled = this.hooks.filter((h) => h.enabled).length;
@@ -114,6 +120,7 @@ export class HookManager {
114
120
  }
115
121
 
116
122
  watch(): void {
123
+ if (getIsContainerized()) return;
117
124
  const hooksDir = getHooksDir();
118
125
  if (!pathExists(hooksDir)) return;
119
126
 
package/src/instrument.ts CHANGED
@@ -2,7 +2,11 @@ import { arch, hostname, platform, release } from "node:os";
2
2
 
3
3
  import * as Sentry from "@sentry/node";
4
4
 
5
- import { getPlatformOrganizationId, getSentryDsn } from "./config/env.js";
5
+ import {
6
+ getPlatformOrganizationId,
7
+ getPlatformUserId,
8
+ getSentryDsn,
9
+ } from "./config/env.js";
6
10
  import { APP_VERSION, COMMIT_SHA } from "./version.js";
7
11
 
8
12
  /** Patterns that match sensitive data in Sentry event values. */
@@ -51,6 +55,7 @@ export function initSentry(): void {
51
55
  initialScope: {
52
56
  tags: {
53
57
  commit: COMMIT_SHA,
58
+ assistant_version: APP_VERSION,
54
59
  os_platform: platform(),
55
60
  os_release: release(),
56
61
  os_arch: arch(),
@@ -58,9 +63,14 @@ export function initSentry(): void {
58
63
  runtime: "bun",
59
64
  runtime_version:
60
65
  typeof Bun !== "undefined" ? Bun.version : process.version,
66
+ // NOTE: device_id is NOT set here. It is deferred to setSentryDeviceId()
67
+ // which is called after workspace migrations run, so that migration
68
+ // 003-seed-device-id can copy the legacy installationId into device.json
69
+ // before getDeviceId() eagerly creates a new random UUID.
61
70
  ...(getPlatformOrganizationId()
62
71
  ? { organization_id: getPlatformOrganizationId() }
63
72
  : {}),
73
+ ...(getPlatformUserId() ? { user_id: getPlatformUserId() } : {}),
64
74
  },
65
75
  },
66
76
  beforeSend(event) {
@@ -109,6 +119,28 @@ export function setSentryOrganizationId(
109
119
  Sentry.setTag("organization_id", organizationId || undefined);
110
120
  }
111
121
 
122
+ /**
123
+ * Set (or clear) the user_id tag on the global Sentry scope.
124
+ *
125
+ * Called after the platform user ID is rehydrated from the credential
126
+ * store or updated at runtime so that every subsequent Sentry event
127
+ * includes the user context.
128
+ */
129
+ export function setSentryUserId(userId: string | undefined): void {
130
+ Sentry.setTag("user_id", userId || undefined);
131
+ }
132
+
133
+ /**
134
+ * Set the device_id tag on the global Sentry scope.
135
+ *
136
+ * Called after workspace migrations complete so that migration
137
+ * 003-seed-device-id has a chance to copy the legacy installationId
138
+ * into device.json before getDeviceId() is invoked.
139
+ */
140
+ export function setSentryDeviceId(deviceId: string): void {
141
+ Sentry.setTag("device_id", deviceId);
142
+ }
143
+
112
144
  // ── Dynamic conversation-scoped Sentry tags ─────────────────────────
113
145
  //
114
146
  // These tags change per conversation turn and are set on the current