@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.
- package/Dockerfile +3 -4
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +113 -0
- package/src/__tests__/config-schema.test.ts +2 -2
- package/src/__tests__/context-window-manager.test.ts +78 -0
- package/src/__tests__/conversation-title-service.test.ts +30 -1
- package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
- package/src/__tests__/memory-regressions.test.ts +8 -30
- package/src/__tests__/require-fresh-approval.test.ts +4 -0
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
- package/src/__tests__/tool-executor.test.ts +4 -0
- package/src/cli/commands/conversations.ts +0 -18
- package/src/config/env.ts +8 -2
- package/src/config/feature-flag-registry.json +0 -8
- package/src/config/schema.ts +0 -12
- package/src/config/schemas/memory.ts +0 -4
- package/src/config/schemas/platform.ts +1 -1
- package/src/config/schemas/security.ts +4 -0
- package/src/context/window-manager.ts +53 -2
- package/src/daemon/config-watcher.ts +1 -4
- package/src/daemon/conversation-agent-loop.ts +0 -60
- package/src/daemon/conversation-memory.ts +0 -117
- package/src/daemon/conversation-runtime-assembly.ts +0 -2
- package/src/daemon/handlers/conversations.ts +0 -11
- package/src/daemon/lifecycle.ts +3 -46
- package/src/followups/followup-store.ts +5 -2
- package/src/memory/conversation-crud.ts +0 -236
- package/src/memory/conversation-title-service.ts +26 -10
- package/src/memory/db-init.ts +5 -13
- package/src/memory/indexer.ts +15 -106
- package/src/memory/job-handlers/embedding.ts +0 -79
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +0 -8
- package/src/memory/jobs-worker.ts +0 -20
- package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
- package/src/memory/migrations/index.ts +1 -3
- package/src/memory/qdrant-client.ts +4 -6
- package/src/memory/schema/conversations.ts +0 -3
- package/src/memory/schema/index.ts +0 -2
- package/src/messaging/draft-store.ts +2 -2
- package/src/permissions/defaults.ts +3 -3
- package/src/permissions/trust-client.ts +2 -13
- package/src/permissions/trust-store.ts +8 -3
- package/src/runtime/auth/route-policy.ts +14 -0
- package/src/runtime/auth/token-service.ts +133 -0
- package/src/runtime/http-server.ts +2 -0
- package/src/runtime/routes/conversation-management-routes.ts +0 -36
- package/src/runtime/routes/conversation-query-routes.ts +44 -2
- package/src/runtime/routes/conversation-routes.ts +2 -1
- package/src/runtime/routes/memory-item-routes.test.ts +221 -3
- package/src/runtime/routes/memory-item-routes.ts +124 -2
- package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
- package/src/schedule/schedule-store.ts +0 -21
- package/src/skills/inline-command-render.ts +5 -1
- package/src/skills/inline-command-runner.ts +30 -2
- package/src/tools/memory/handlers.ts +1 -129
- package/src/tools/permission-checker.ts +18 -0
- package/src/tools/skills/load.ts +9 -2
- package/src/util/platform.ts +5 -5
- package/src/util/xml.ts +8 -0
- package/src/workspace/heartbeat-service.ts +5 -24
- package/src/__tests__/archive-recall.test.ts +0 -560
- package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
- package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
- package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
- package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
- package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
- package/src/__tests__/memory-brief-time.test.ts +0 -285
- package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
- package/src/__tests__/memory-chunk-archive.test.ts +0 -400
- package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
- package/src/__tests__/memory-episode-archive.test.ts +0 -370
- package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
- package/src/__tests__/memory-observation-archive.test.ts +0 -375
- package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
- package/src/__tests__/memory-reducer-job.test.ts +0 -538
- package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
- package/src/__tests__/memory-reducer-store.test.ts +0 -728
- package/src/__tests__/memory-reducer-types.test.ts +0 -707
- package/src/__tests__/memory-reducer.test.ts +0 -704
- package/src/__tests__/memory-simplified-config.test.ts +0 -281
- package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
- package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
- package/src/config/schemas/memory-simplified.ts +0 -101
- package/src/memory/archive-recall.ts +0 -516
- package/src/memory/archive-store.ts +0 -400
- package/src/memory/brief-formatting.ts +0 -33
- package/src/memory/brief-open-loops.ts +0 -266
- package/src/memory/brief-time.ts +0 -162
- package/src/memory/brief.ts +0 -75
- package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
- package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
- package/src/memory/migrations/185-memory-brief-state.ts +0 -52
- package/src/memory/migrations/186-memory-archive.ts +0 -109
- package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
- package/src/memory/reducer-scheduler.ts +0 -242
- package/src/memory/reducer-store.ts +0 -271
- package/src/memory/reducer-types.ts +0 -106
- package/src/memory/reducer.ts +0 -467
- package/src/memory/schema/memory-archive.ts +0 -121
- 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
|
|
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
|
-
//
|
|
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
|
+
}
|