@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
@@ -37,13 +37,55 @@ mock.module("../../util/logger.js", () => ({
37
37
  }),
38
38
  }));
39
39
 
40
- // Stub config loader
40
+ // Stub config loader — returns a config with memory enabled by default
41
41
  mock.module("../../config/loader.js", () => ({
42
- loadConfig: () => ({}),
43
- getConfig: () => ({}),
42
+ loadConfig: () => mockConfig,
43
+ getConfig: () => mockConfig,
44
44
  invalidateConfigCache: () => {},
45
45
  }));
46
46
 
47
+ // ── Controllable mocks for semantic search ─────────────────────────────
48
+ const mockConfig: unknown = {};
49
+
50
+ let mockBackendStatus: {
51
+ enabled: boolean;
52
+ provider: string | null;
53
+ model: string | null;
54
+ } = { enabled: false, provider: null, model: null };
55
+
56
+ const mockEmbedResult: {
57
+ provider: string;
58
+ model: string;
59
+ vectors: number[][];
60
+ } = { provider: "local", model: "test", vectors: [[0.1, 0.2, 0.3]] };
61
+
62
+ let mockHybridSearchResults: Array<{
63
+ id: string;
64
+ score: number;
65
+ payload: Record<string, unknown>;
66
+ }> = [];
67
+
68
+ mock.module("../../memory/embedding-backend.js", () => ({
69
+ getMemoryBackendStatus: async () => mockBackendStatus,
70
+ embedWithBackend: async () => mockEmbedResult,
71
+ generateSparseEmbedding: () => ({
72
+ indices: [0, 1, 2],
73
+ values: [0.5, 0.3, 0.2],
74
+ }),
75
+ }));
76
+
77
+ mock.module("../../memory/qdrant-client.js", () => ({
78
+ getQdrantClient: () => ({
79
+ hybridSearch: async () => [...mockHybridSearchResults],
80
+ searchWithFilter: async () => [...mockHybridSearchResults],
81
+ }),
82
+ initQdrantClient: () => {},
83
+ }));
84
+
85
+ mock.module("../../memory/qdrant-circuit-breaker.js", () => ({
86
+ withQdrantBreaker: async (fn: () => Promise<unknown>) => fn(),
87
+ }));
88
+
47
89
  import { and, eq } from "drizzle-orm";
48
90
 
49
91
  import { getDb, initializeDb, resetDb } from "../../memory/db.js";
@@ -392,6 +434,182 @@ describe("Memory Item Routes", () => {
392
434
  const res = await handler(ctx);
393
435
  expect(res.status).toBe(400);
394
436
  });
437
+
438
+ // ── Semantic / hybrid search ──────────────────────────────────────
439
+
440
+ test("uses semantic search when embedding backend is available", async () => {
441
+ insertItem({
442
+ id: "i1",
443
+ kind: "preference",
444
+ subject: "dark mode",
445
+ statement: "User prefers dark mode",
446
+ });
447
+ insertItem({
448
+ id: "i2",
449
+ kind: "identity",
450
+ subject: "name",
451
+ statement: "User name is Alice",
452
+ });
453
+
454
+ // Enable semantic search
455
+ mockBackendStatus = {
456
+ enabled: true,
457
+ provider: "local",
458
+ model: "test",
459
+ };
460
+ // Qdrant returns i2 first (higher relevance), then i1
461
+ mockHybridSearchResults = [
462
+ {
463
+ id: "pt-2",
464
+ score: 0.95,
465
+ payload: { target_type: "item", target_id: "i2" },
466
+ },
467
+ {
468
+ id: "pt-1",
469
+ score: 0.7,
470
+ payload: { target_type: "item", target_id: "i1" },
471
+ },
472
+ ];
473
+
474
+ const ctx = makeCtx({ search: "alice" });
475
+ const res = await handler(ctx);
476
+ const body = (await res.json()) as {
477
+ items: Array<{ id: string }>;
478
+ total: number;
479
+ };
480
+
481
+ // Results in Qdrant relevance order (i2 first)
482
+ expect(body.items.length).toBe(2);
483
+ expect(body.items[0].id).toBe("i2");
484
+ expect(body.items[1].id).toBe("i1");
485
+ expect(body.total).toBe(2);
486
+
487
+ // Reset
488
+ mockBackendStatus = { enabled: false, provider: null, model: null };
489
+ mockHybridSearchResults = [];
490
+ });
491
+
492
+ test("falls back to SQL LIKE when backend is unavailable", async () => {
493
+ insertItem({
494
+ id: "i1",
495
+ kind: "preference",
496
+ subject: "dark mode",
497
+ statement: "User prefers dark mode",
498
+ });
499
+ insertItem({
500
+ id: "i2",
501
+ kind: "identity",
502
+ subject: "name",
503
+ statement: "User name is Alice",
504
+ });
505
+
506
+ // Backend unavailable
507
+ mockBackendStatus = { enabled: false, provider: null, model: null };
508
+ mockHybridSearchResults = [];
509
+
510
+ const ctx = makeCtx({ search: "dark" });
511
+ const res = await handler(ctx);
512
+ const body = (await res.json()) as {
513
+ items: Array<{ id: string }>;
514
+ total: number;
515
+ };
516
+
517
+ // SQL LIKE fallback finds "dark" in subject/statement
518
+ expect(body.total).toBe(1);
519
+ expect(body.items[0].id).toBe("i1");
520
+ });
521
+
522
+ test("semantic search respects pagination", async () => {
523
+ insertItem({
524
+ id: "i1",
525
+ kind: "preference",
526
+ subject: "s1",
527
+ statement: "first item",
528
+ });
529
+ insertItem({
530
+ id: "i2",
531
+ kind: "preference",
532
+ subject: "s2",
533
+ statement: "second item",
534
+ });
535
+ insertItem({
536
+ id: "i3",
537
+ kind: "preference",
538
+ subject: "s3",
539
+ statement: "third item",
540
+ });
541
+
542
+ mockBackendStatus = {
543
+ enabled: true,
544
+ provider: "local",
545
+ model: "test",
546
+ };
547
+ mockHybridSearchResults = [
548
+ {
549
+ id: "pt-1",
550
+ score: 0.9,
551
+ payload: { target_type: "item", target_id: "i1" },
552
+ },
553
+ {
554
+ id: "pt-2",
555
+ score: 0.8,
556
+ payload: { target_type: "item", target_id: "i2" },
557
+ },
558
+ {
559
+ id: "pt-3",
560
+ score: 0.7,
561
+ payload: { target_type: "item", target_id: "i3" },
562
+ },
563
+ ];
564
+
565
+ // Request page 2 (offset=1, limit=1)
566
+ const ctx = makeCtx({ search: "item", limit: "1", offset: "1" });
567
+ const res = await handler(ctx);
568
+ const body = (await res.json()) as {
569
+ items: Array<{ id: string }>;
570
+ total: number;
571
+ };
572
+
573
+ expect(body.items.length).toBe(1);
574
+ expect(body.items[0].id).toBe("i2"); // second by relevance
575
+ expect(body.total).toBe(3);
576
+
577
+ // Reset
578
+ mockBackendStatus = { enabled: false, provider: null, model: null };
579
+ mockHybridSearchResults = [];
580
+ });
581
+
582
+ test("falls back to SQL when semantic returns empty results", async () => {
583
+ insertItem({
584
+ id: "i1",
585
+ kind: "preference",
586
+ subject: "dark mode",
587
+ statement: "User prefers dark mode",
588
+ });
589
+
590
+ mockBackendStatus = {
591
+ enabled: true,
592
+ provider: "local",
593
+ model: "test",
594
+ };
595
+ // Qdrant returns nothing
596
+ mockHybridSearchResults = [];
597
+
598
+ const ctx = makeCtx({ search: "dark" });
599
+ const res = await handler(ctx);
600
+ const body = (await res.json()) as {
601
+ items: Array<{ id: string }>;
602
+ total: number;
603
+ };
604
+
605
+ // Falls through to SQL LIKE
606
+ expect(body.total).toBe(1);
607
+ expect(body.items[0].id).toBe("i1");
608
+
609
+ // Reset
610
+ mockBackendStatus = { enabled: false, provider: null, model: null };
611
+ mockHybridSearchResults = [];
612
+ });
395
613
  });
396
614
 
397
615
  // =========================================================================
@@ -11,18 +11,29 @@
11
11
  import { and, asc, count, desc, eq, inArray, like, ne, or } from "drizzle-orm";
12
12
  import { v4 as uuid } from "uuid";
13
13
 
14
+ import { getConfig } from "../../config/loader.js";
14
15
  import { getDb } from "../../memory/db.js";
16
+ import {
17
+ embedWithBackend,
18
+ generateSparseEmbedding,
19
+ getMemoryBackendStatus,
20
+ } from "../../memory/embedding-backend.js";
15
21
  import { computeMemoryFingerprint } from "../../memory/fingerprint.js";
16
22
  import { enqueueMemoryJob } from "../../memory/jobs-store.js";
23
+ import { withQdrantBreaker } from "../../memory/qdrant-circuit-breaker.js";
24
+ import { getQdrantClient } from "../../memory/qdrant-client.js";
17
25
  import {
18
26
  conversations,
19
27
  memoryEmbeddings,
20
28
  memoryItems,
21
29
  } from "../../memory/schema.js";
30
+ import { getLogger } from "../../util/logger.js";
22
31
  import { truncate } from "../../util/truncate.js";
23
32
  import { httpError } from "../http-errors.js";
24
33
  import type { RouteContext, RouteDefinition } from "../http-router.js";
25
34
 
35
+ const log = getLogger("memory-item-routes");
36
+
26
37
  // ---------------------------------------------------------------------------
27
38
  // Constants
28
39
  // ---------------------------------------------------------------------------
@@ -116,11 +127,76 @@ function buildConversationTitleMap(
116
127
  return map;
117
128
  }
118
129
 
130
+ // ---------------------------------------------------------------------------
131
+ // Semantic search helper
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Attempt hybrid semantic search for memory items via Qdrant.
136
+ * Returns ordered item IDs + total count on success, or `null` when
137
+ * the embedding backend / Qdrant is unavailable (caller falls back to SQL).
138
+ */
139
+ async function searchItemsSemantic(
140
+ query: string,
141
+ fetchLimit: number,
142
+ kindFilter: string | null,
143
+ statusFilter: string,
144
+ ): Promise<{ ids: string[]; total: number } | null> {
145
+ try {
146
+ const config = getConfig();
147
+ const backendStatus = await getMemoryBackendStatus(config);
148
+ if (!backendStatus.provider) return null;
149
+
150
+ const embedded = await embedWithBackend(config, [query]);
151
+ const queryVector = embedded.vectors[0];
152
+ if (!queryVector) return null;
153
+
154
+ const sparse = generateSparseEmbedding(query);
155
+ const sparseVector = { indices: sparse.indices, values: sparse.values };
156
+
157
+ // Build Qdrant filter — items only, exclude capability kind and sentinel
158
+ const mustConditions: Array<Record<string, unknown>> = [
159
+ { key: "target_type", match: { value: "item" } },
160
+ ];
161
+ if (statusFilter && statusFilter !== "all") {
162
+ mustConditions.push({ key: "status", match: { value: statusFilter } });
163
+ }
164
+ if (kindFilter) {
165
+ mustConditions.push({ key: "kind", match: { value: kindFilter } });
166
+ }
167
+
168
+ const filter = {
169
+ must: mustConditions,
170
+ must_not: [
171
+ { key: "kind", match: { value: "capability" } },
172
+ { key: "_meta", match: { value: true } },
173
+ ],
174
+ };
175
+
176
+ const qdrant = getQdrantClient();
177
+ const results = await withQdrantBreaker(() =>
178
+ qdrant.hybridSearch({
179
+ denseVector: queryVector,
180
+ sparseVector,
181
+ filter,
182
+ limit: fetchLimit,
183
+ prefetchLimit: fetchLimit,
184
+ }),
185
+ );
186
+
187
+ const ids = results.map((r) => r.payload.target_id);
188
+ return { ids, total: ids.length };
189
+ } catch (err) {
190
+ log.warn({ err }, "Semantic memory search failed, falling back to SQL");
191
+ return null;
192
+ }
193
+ }
194
+
119
195
  // ---------------------------------------------------------------------------
120
196
  // GET /v1/memory-items
121
197
  // ---------------------------------------------------------------------------
122
198
 
123
- export function handleListMemoryItems(url: URL): Response {
199
+ export async function handleListMemoryItems(url: URL): Promise<Response> {
124
200
  const kindParam = url.searchParams.get("kind");
125
201
  const statusParam = url.searchParams.get("status") ?? "active";
126
202
  const searchParam = url.searchParams.get("search");
@@ -155,7 +231,53 @@ export function handleListMemoryItems(url: URL): Response {
155
231
 
156
232
  const db = getDb();
157
233
 
158
- // Build WHERE conditions
234
+ // ── Semantic search path ────────────────────────────────────────────
235
+ // When a search query is present, try Qdrant hybrid search first.
236
+ // Falls back to SQL LIKE when embeddings / Qdrant are unavailable.
237
+ if (searchParam) {
238
+ const semanticResult = await searchItemsSemantic(
239
+ searchParam,
240
+ limitParam + offsetParam,
241
+ kindParam,
242
+ statusParam,
243
+ );
244
+
245
+ if (semanticResult && semanticResult.ids.length > 0) {
246
+ // Slice for pagination
247
+ const pageIds = semanticResult.ids.slice(
248
+ offsetParam,
249
+ offsetParam + limitParam,
250
+ );
251
+
252
+ // Batch-fetch full rows from SQLite
253
+ const rows = db
254
+ .select()
255
+ .from(memoryItems)
256
+ .where(inArray(memoryItems.id, pageIds))
257
+ .all();
258
+
259
+ // Preserve Qdrant relevance ordering
260
+ const idOrder = new Map(pageIds.map((id, i) => [id, i]));
261
+ rows.sort((a, b) => (idOrder.get(a.id) ?? 0) - (idOrder.get(b.id) ?? 0));
262
+
263
+ const titleMap = buildConversationTitleMap(
264
+ db,
265
+ rows.map((i) => i.scopeId),
266
+ );
267
+ const enrichedItems = rows.map((item) => ({
268
+ ...item,
269
+ scopeLabel: resolveScopeLabel(item.scopeId, titleMap),
270
+ }));
271
+
272
+ return Response.json({
273
+ items: enrichedItems,
274
+ total: semanticResult.total,
275
+ });
276
+ }
277
+ // semanticResult was null (Qdrant unavailable) or empty — fall through to SQL
278
+ }
279
+
280
+ // ── SQL path (default or fallback) ──────────────────────────────────
159
281
  const conditions = [];
160
282
  // Hide system-managed capability memories (skill announcements) from the UI
161
283
  conditions.push(ne(memoryItems.kind, "capability"));
@@ -10,7 +10,7 @@ import {
10
10
  invalidateConfigCache,
11
11
  } from "../../config/loader.js";
12
12
  import type { CesClient } from "../../credential-execution/client.js";
13
- import { setSentryOrganizationId } from "../../instrument.js";
13
+ import { setSentryOrganizationId, setSentryUserId } from "../../instrument.js";
14
14
  import { clearEmbeddingBackendCache } from "../../memory/embedding-backend.js";
15
15
  import { syncManualTokenConnection } from "../../oauth/manual-token-connection.js";
16
16
  import { validateAnthropicApiKey } from "../../providers/anthropic/client.js";
@@ -235,6 +235,7 @@ export async function handleAddSecret(
235
235
  setSentryOrganizationId(undefined);
236
236
  } else if (field === "platform_user_id") {
237
237
  setPlatformUserId(undefined);
238
+ setSentryUserId(undefined);
238
239
  }
239
240
  deleteCredentialMetadata(service, field);
240
241
  } else {
@@ -260,6 +261,7 @@ export async function handleAddSecret(
260
261
  }
261
262
  if (service === "vellum" && field === "platform_user_id") {
262
263
  setPlatformUserId(effectiveValue || undefined);
264
+ setSentryUserId(effectiveValue || undefined);
263
265
  }
264
266
  }
265
267
  if (isManagedProxyCredential(service, field)) {
@@ -395,6 +397,7 @@ export async function handleDeleteSecret(req: Request): Promise<Response> {
395
397
  }
396
398
  if (service === "vellum" && field === "platform_user_id") {
397
399
  setPlatformUserId(undefined);
400
+ setSentryUserId(undefined);
398
401
  }
399
402
  if (isManagedProxyCredential(service, field)) {
400
403
  await initializeProviders(getConfig());
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Upgrade broadcast endpoint — publishes service group update lifecycle
3
+ * events (starting / complete) to all connected SSE clients.
4
+ *
5
+ * Protected by a route policy restricting access to gateway service
6
+ * principals only (`svc_gateway` with `internal.write` scope), following
7
+ * the same pattern as other gateway-forwarded control-plane endpoints.
8
+ * The gateway requires a valid edge JWT and forwards the request with a
9
+ * minted service token.
10
+ */
11
+
12
+ import type {
13
+ ServiceGroupUpdateComplete,
14
+ ServiceGroupUpdateStarting,
15
+ } from "../../daemon/message-types/upgrades.js";
16
+ import { buildAssistantEvent } from "../assistant-event.js";
17
+ import { assistantEventHub } from "../assistant-event-hub.js";
18
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from "../assistant-scope.js";
19
+ import { httpError } from "../http-errors.js";
20
+ import type { RouteDefinition } from "../http-router.js";
21
+
22
+ export function upgradeBroadcastRouteDefinitions(): RouteDefinition[] {
23
+ return [
24
+ {
25
+ endpoint: "admin/upgrade-broadcast",
26
+ method: "POST",
27
+ handler: async ({ req }) => {
28
+ let body: unknown;
29
+ try {
30
+ body = await req.json();
31
+ } catch {
32
+ return httpError("BAD_REQUEST", "Invalid JSON body", 400);
33
+ }
34
+
35
+ if (!body || typeof body !== "object") {
36
+ return httpError(
37
+ "BAD_REQUEST",
38
+ "Request body must be a JSON object",
39
+ 400,
40
+ );
41
+ }
42
+
43
+ const { type } = body as { type?: unknown };
44
+
45
+ if (type === "starting") {
46
+ const { targetVersion, expectedDowntimeSeconds } = body as {
47
+ targetVersion?: unknown;
48
+ expectedDowntimeSeconds?: unknown;
49
+ };
50
+
51
+ if (typeof targetVersion !== "string" || targetVersion.length === 0) {
52
+ return httpError(
53
+ "BAD_REQUEST",
54
+ "targetVersion is required and must be a non-empty string",
55
+ 400,
56
+ );
57
+ }
58
+
59
+ const downtime =
60
+ expectedDowntimeSeconds === undefined
61
+ ? 60
62
+ : expectedDowntimeSeconds;
63
+
64
+ if (
65
+ typeof downtime !== "number" ||
66
+ !isFinite(downtime) ||
67
+ downtime < 0
68
+ ) {
69
+ return httpError(
70
+ "BAD_REQUEST",
71
+ "expectedDowntimeSeconds must be a non-negative number",
72
+ 400,
73
+ );
74
+ }
75
+
76
+ const message: ServiceGroupUpdateStarting = {
77
+ type: "service_group_update_starting",
78
+ targetVersion,
79
+ expectedDowntimeSeconds: downtime,
80
+ };
81
+
82
+ await assistantEventHub.publish(
83
+ buildAssistantEvent(DAEMON_INTERNAL_ASSISTANT_ID, message),
84
+ );
85
+
86
+ return Response.json({ ok: true });
87
+ }
88
+
89
+ if (type === "complete") {
90
+ const { installedVersion, success, rolledBackToVersion } = body as {
91
+ installedVersion?: unknown;
92
+ success?: unknown;
93
+ rolledBackToVersion?: unknown;
94
+ };
95
+
96
+ if (
97
+ typeof installedVersion !== "string" ||
98
+ installedVersion.length === 0
99
+ ) {
100
+ return httpError(
101
+ "BAD_REQUEST",
102
+ "installedVersion is required and must be a non-empty string",
103
+ 400,
104
+ );
105
+ }
106
+
107
+ if (typeof success !== "boolean") {
108
+ return httpError(
109
+ "BAD_REQUEST",
110
+ "success is required and must be a boolean",
111
+ 400,
112
+ );
113
+ }
114
+
115
+ if (
116
+ rolledBackToVersion !== undefined &&
117
+ (typeof rolledBackToVersion !== "string" ||
118
+ rolledBackToVersion.length === 0)
119
+ ) {
120
+ return httpError(
121
+ "BAD_REQUEST",
122
+ "rolledBackToVersion must be a non-empty string when provided",
123
+ 400,
124
+ );
125
+ }
126
+
127
+ const message: ServiceGroupUpdateComplete = {
128
+ type: "service_group_update_complete",
129
+ installedVersion,
130
+ success,
131
+ ...(typeof rolledBackToVersion === "string"
132
+ ? { rolledBackToVersion }
133
+ : {}),
134
+ };
135
+
136
+ await assistantEventHub.publish(
137
+ buildAssistantEvent(DAEMON_INTERNAL_ASSISTANT_ID, message),
138
+ );
139
+
140
+ return Response.json({ ok: true });
141
+ }
142
+
143
+ return httpError(
144
+ "BAD_REQUEST",
145
+ 'type must be "starting" or "complete"',
146
+ 400,
147
+ );
148
+ },
149
+ },
150
+ ];
151
+ }
@@ -206,27 +206,6 @@ export function listSchedules(options?: {
206
206
  return rows.map(parseJobRow);
207
207
  }
208
208
 
209
- /**
210
- * Return enabled schedules whose next run falls within a time window.
211
- * Used by the memory brief compiler to surface due-soon schedule entries.
212
- */
213
- export function getDueSoonSchedules(
214
- now: number,
215
- horizonMs: number,
216
- ): ScheduleJob[] {
217
- const db = getDb();
218
- const cutoff = now + horizonMs;
219
- const rows = db
220
- .select()
221
- .from(scheduleJobs)
222
- .where(
223
- and(eq(scheduleJobs.enabled, true), lte(scheduleJobs.nextRunAt, cutoff)),
224
- )
225
- .orderBy(asc(scheduleJobs.nextRunAt))
226
- .all();
227
- return rows.map(parseJobRow);
228
- }
229
-
230
209
  export function updateSchedule(
231
210
  id: string,
232
211
  updates: {