@vellumai/assistant 0.5.5 → 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 (102) hide show
  1. package/Dockerfile +3 -4
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-token-service.test.ts +113 -0
  4. package/src/__tests__/config-schema.test.ts +2 -2
  5. package/src/__tests__/context-window-manager.test.ts +78 -0
  6. package/src/__tests__/conversation-title-service.test.ts +30 -1
  7. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  8. package/src/__tests__/memory-regressions.test.ts +8 -30
  9. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  10. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  11. package/src/__tests__/tool-executor.test.ts +4 -0
  12. package/src/cli/commands/conversations.ts +0 -18
  13. package/src/config/env.ts +8 -2
  14. package/src/config/feature-flag-registry.json +0 -8
  15. package/src/config/schema.ts +0 -12
  16. package/src/config/schemas/memory.ts +0 -4
  17. package/src/config/schemas/platform.ts +1 -1
  18. package/src/config/schemas/security.ts +4 -0
  19. package/src/context/window-manager.ts +53 -2
  20. package/src/daemon/config-watcher.ts +1 -4
  21. package/src/daemon/conversation-agent-loop.ts +0 -60
  22. package/src/daemon/conversation-memory.ts +0 -117
  23. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  24. package/src/daemon/handlers/conversations.ts +0 -11
  25. package/src/daemon/lifecycle.ts +3 -46
  26. package/src/followups/followup-store.ts +5 -2
  27. package/src/memory/conversation-crud.ts +0 -236
  28. package/src/memory/conversation-title-service.ts +26 -10
  29. package/src/memory/db-init.ts +5 -13
  30. package/src/memory/indexer.ts +15 -106
  31. package/src/memory/job-handlers/embedding.ts +0 -79
  32. package/src/memory/job-utils.ts +1 -1
  33. package/src/memory/jobs-store.ts +0 -8
  34. package/src/memory/jobs-worker.ts +0 -20
  35. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  36. package/src/memory/migrations/index.ts +1 -3
  37. package/src/memory/qdrant-client.ts +4 -6
  38. package/src/memory/schema/conversations.ts +0 -3
  39. package/src/memory/schema/index.ts +0 -2
  40. package/src/messaging/draft-store.ts +2 -2
  41. package/src/permissions/defaults.ts +3 -3
  42. package/src/permissions/trust-client.ts +2 -13
  43. package/src/permissions/trust-store.ts +8 -3
  44. package/src/runtime/auth/route-policy.ts +14 -0
  45. package/src/runtime/auth/token-service.ts +133 -0
  46. package/src/runtime/http-server.ts +2 -0
  47. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  48. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  49. package/src/runtime/routes/conversation-routes.ts +2 -1
  50. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  51. package/src/runtime/routes/memory-item-routes.ts +124 -2
  52. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  53. package/src/schedule/schedule-store.ts +0 -21
  54. package/src/skills/inline-command-render.ts +5 -1
  55. package/src/skills/inline-command-runner.ts +30 -2
  56. package/src/tools/memory/handlers.ts +1 -129
  57. package/src/tools/permission-checker.ts +18 -0
  58. package/src/tools/skills/load.ts +9 -2
  59. package/src/util/platform.ts +5 -5
  60. package/src/util/xml.ts +8 -0
  61. package/src/workspace/heartbeat-service.ts +5 -24
  62. package/src/__tests__/archive-recall.test.ts +0 -560
  63. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  64. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  65. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  66. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  67. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  68. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  69. package/src/__tests__/memory-brief-time.test.ts +0 -285
  70. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  71. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  72. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  73. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  74. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  75. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  76. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  77. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  78. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  79. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  80. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  81. package/src/__tests__/memory-reducer.test.ts +0 -704
  82. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  83. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  84. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  85. package/src/config/schemas/memory-simplified.ts +0 -101
  86. package/src/memory/archive-recall.ts +0 -516
  87. package/src/memory/archive-store.ts +0 -400
  88. package/src/memory/brief-formatting.ts +0 -33
  89. package/src/memory/brief-open-loops.ts +0 -266
  90. package/src/memory/brief-time.ts +0 -162
  91. package/src/memory/brief.ts +0 -75
  92. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  93. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  94. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  95. package/src/memory/migrations/186-memory-archive.ts +0 -109
  96. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  97. package/src/memory/reducer-scheduler.ts +0 -242
  98. package/src/memory/reducer-store.ts +0 -271
  99. package/src/memory/reducer-types.ts +0 -106
  100. package/src/memory/reducer.ts +0 -467
  101. package/src/memory/schema/memory-archive.ts +0 -121
  102. package/src/memory/schema/memory-brief.ts +0 -55
@@ -275,24 +275,6 @@ export function conversationManagementRouteDefinitions(
275
275
  targetId: summaryId,
276
276
  });
277
277
  }
278
- for (const obsId of result.deletedObservationIds) {
279
- enqueueMemoryJob("delete_qdrant_vectors", {
280
- targetType: "observation",
281
- targetId: obsId,
282
- });
283
- }
284
- for (const chunkId of result.deletedChunkIds) {
285
- enqueueMemoryJob("delete_qdrant_vectors", {
286
- targetType: "chunk",
287
- targetId: chunkId,
288
- });
289
- }
290
- for (const episodeId of result.deletedEpisodeIds) {
291
- enqueueMemoryJob("delete_qdrant_vectors", {
292
- targetType: "episode",
293
- targetId: episodeId,
294
- });
295
- }
296
278
  log.info(
297
279
  {
298
280
  conversationId: resolvedId,
@@ -349,24 +331,6 @@ export function conversationManagementRouteDefinitions(
349
331
  targetId: summaryId,
350
332
  });
351
333
  }
352
- for (const obsId of deleted.deletedObservationIds) {
353
- enqueueMemoryJob("delete_qdrant_vectors", {
354
- targetType: "observation",
355
- targetId: obsId,
356
- });
357
- }
358
- for (const chunkId of deleted.deletedChunkIds) {
359
- enqueueMemoryJob("delete_qdrant_vectors", {
360
- targetType: "chunk",
361
- targetId: chunkId,
362
- });
363
- }
364
- for (const episodeId of deleted.deletedEpisodeIds) {
365
- enqueueMemoryJob("delete_qdrant_vectors", {
366
- targetType: "episode",
367
- targetId: episodeId,
368
- });
369
- }
370
334
  log.info({ conversationId: resolvedId }, "Deleted conversation");
371
335
  return new Response(null, { status: 204 });
372
336
  },
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * HTTP route definitions for model configuration, embedding configuration,
3
- * conversation search, message content, LLM context inspection, and queued
4
- * message deletion.
3
+ * permissions configuration, conversation search, message content, LLM
4
+ * context inspection, and queued message deletion.
5
5
  *
6
6
  * These routes expose conversation query functionality over the HTTP API.
7
7
  *
@@ -10,12 +10,15 @@
10
10
  * PUT /v1/model/image-gen — set image-gen model
11
11
  * GET /v1/config/embeddings — current embedding config
12
12
  * PUT /v1/config/embeddings — set embedding provider/model
13
+ * GET /v1/config/permissions/skip — dangerouslySkipPermissions status
14
+ * PUT /v1/config/permissions/skip — toggle dangerouslySkipPermissions
13
15
  * GET /v1/conversations/search — search conversations
14
16
  * GET /v1/messages/:id/content — full message content
15
17
  * GET /v1/messages/:id/llm-context — LLM request logs for a message
16
18
  * DELETE /v1/messages/queued/:id — delete queued message
17
19
  */
18
20
 
21
+ import { getConfig, loadRawConfig, saveRawConfig } from "../../config/loader.js";
19
22
  import { VALID_MEMORY_EMBEDDING_PROVIDERS } from "../../config/schemas/memory-storage.js";
20
23
  import { VALID_INFERENCE_PROVIDERS } from "../../config/schemas/services.js";
21
24
  import {
@@ -250,6 +253,45 @@ export function conversationQueryRouteDefinitions(
250
253
  },
251
254
  },
252
255
 
256
+ // ── Permissions config ─────────────────────────────────────────────
257
+ {
258
+ endpoint: "config/permissions/skip",
259
+ method: "GET",
260
+ policyKey: "config/permissions/skip",
261
+ handler: () => {
262
+ const config = getConfig();
263
+ return Response.json({
264
+ enabled: config.permissions.dangerouslySkipPermissions,
265
+ });
266
+ },
267
+ },
268
+ {
269
+ endpoint: "config/permissions/skip",
270
+ method: "PUT",
271
+ policyKey: "config/permissions/skip",
272
+ handler: async ({ req }) => {
273
+ const body = (await req.json()) as { enabled?: unknown };
274
+ if (typeof body.enabled !== "boolean") {
275
+ return httpError(
276
+ "BAD_REQUEST",
277
+ "Missing or invalid field: enabled (boolean)",
278
+ 400,
279
+ );
280
+ }
281
+ const raw = loadRawConfig();
282
+ const permissions: Record<string, unknown> =
283
+ raw.permissions != null &&
284
+ typeof raw.permissions === "object" &&
285
+ !Array.isArray(raw.permissions)
286
+ ? (raw.permissions as Record<string, unknown>)
287
+ : {};
288
+ permissions.dangerouslySkipPermissions = body.enabled;
289
+ raw.permissions = permissions;
290
+ saveRawConfig(raw);
291
+ return Response.json({ enabled: body.enabled });
292
+ },
293
+ },
294
+
253
295
  // ── Conversation search ───────────────────────────────────────────
254
296
  {
255
297
  endpoint: "conversations/search",
@@ -637,6 +637,7 @@ export async function handleSendMessage(
637
637
  interface?: string;
638
638
  conversationType?: string;
639
639
  automated?: boolean;
640
+ bypassSecretCheck?: boolean;
640
641
  };
641
642
 
642
643
  const { conversationKey, content, attachmentIds } = body;
@@ -708,7 +709,7 @@ export async function handleSendMessage(
708
709
  // This mirrors the legacy handleUserMessage behavior: secrets are
709
710
  // detected and the message is rejected with a safe notice. The client
710
711
  // should prompt the user to use the secure credential flow instead.
711
- if (trimmedContent.length > 0) {
712
+ if (trimmedContent.length > 0 && !body.bypassSecretCheck) {
712
713
  const ingressCheck = checkIngressForSecrets(trimmedContent);
713
714
  if (ingressCheck.blocked) {
714
715
  log.warn(
@@ -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"));
@@ -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
+ }