@vellumai/assistant 0.3.4 → 0.3.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.
- package/Dockerfile +2 -0
- package/README.md +37 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +13 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
- package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
- package/src/__tests__/approval-message-composer.test.ts +253 -0
- package/src/__tests__/call-domain.test.ts +12 -2
- package/src/__tests__/call-orchestrator.test.ts +70 -1
- package/src/__tests__/call-routes-http.test.ts +27 -2
- package/src/__tests__/channel-approval-routes.test.ts +21 -17
- package/src/__tests__/channel-approvals.test.ts +48 -1
- package/src/__tests__/channel-guardian.test.ts +74 -22
- package/src/__tests__/channel-readiness-service.test.ts +257 -0
- package/src/__tests__/config-schema.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +13 -12
- package/src/__tests__/dictation-mode-detection.test.ts +63 -0
- package/src/__tests__/entity-search.test.ts +615 -0
- package/src/__tests__/handlers-twilio-config.test.ts +407 -0
- package/src/__tests__/ipc-snapshot.test.ts +63 -0
- package/src/__tests__/messaging-send-tool.test.ts +65 -0
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
- package/src/__tests__/run-orchestrator.test.ts +22 -0
- package/src/__tests__/session-runtime-assembly.test.ts +85 -1
- package/src/__tests__/sms-messaging-provider.test.ts +125 -0
- package/src/__tests__/twilio-routes.test.ts +39 -3
- package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
- package/src/__tests__/web-search.test.ts +1 -1
- package/src/__tests__/work-item-output.test.ts +110 -0
- package/src/calls/call-domain.ts +8 -5
- package/src/calls/call-orchestrator.ts +22 -11
- package/src/calls/twilio-config.ts +17 -11
- package/src/calls/twilio-rest.ts +276 -0
- package/src/calls/twilio-routes.ts +39 -1
- package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
- package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
- package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
- package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
- package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
- package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
- package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
- package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
- package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
- package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
- package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
- package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
- package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
- package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
- package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
- package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
- package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
- package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
- package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
- package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
- package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
- package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
- package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
- package/src/config/bundled-skills/messaging/SKILL.md +21 -6
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/twitter/SKILL.md +19 -3
- package/src/config/defaults.ts +2 -1
- package/src/config/schema.ts +9 -3
- package/src/config/system-prompt.ts +24 -0
- package/src/config/templates/IDENTITY.md +2 -2
- package/src/config/vellum-skills/catalog.json +6 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
- package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +40 -8
- package/src/daemon/handlers/config.ts +783 -9
- package/src/daemon/handlers/dictation.ts +182 -0
- package/src/daemon/handlers/identity.ts +14 -23
- package/src/daemon/handlers/index.ts +2 -0
- package/src/daemon/handlers/sessions.ts +2 -0
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/work-items.ts +15 -7
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +108 -4
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/ride-shotgun-handler.ts +1 -1
- package/src/daemon/server.ts +6 -2
- package/src/daemon/session-agent-loop.ts +5 -1
- package/src/daemon/session-runtime-assembly.ts +55 -0
- package/src/daemon/session-tool-setup.ts +2 -0
- package/src/daemon/session.ts +11 -1
- package/src/inbound/public-ingress-urls.ts +3 -3
- package/src/memory/channel-guardian-store.ts +2 -1
- package/src/memory/db-init.ts +144 -0
- package/src/memory/job-handlers/media-processing.ts +100 -0
- package/src/memory/jobs-store.ts +2 -1
- package/src/memory/jobs-worker.ts +4 -0
- package/src/memory/media-store.ts +759 -0
- package/src/memory/retriever.ts +6 -1
- package/src/memory/schema.ts +98 -0
- package/src/memory/search/entity.ts +208 -25
- package/src/memory/search/ranking.ts +6 -1
- package/src/memory/search/types.ts +24 -0
- package/src/messaging/provider-types.ts +2 -0
- package/src/messaging/providers/sms/adapter.ts +204 -0
- package/src/messaging/providers/sms/client.ts +93 -0
- package/src/messaging/providers/sms/types.ts +7 -0
- package/src/permissions/checker.ts +16 -2
- package/src/runtime/approval-message-composer.ts +143 -0
- package/src/runtime/channel-approvals.ts +12 -4
- package/src/runtime/channel-guardian-service.ts +44 -18
- package/src/runtime/channel-readiness-service.ts +292 -0
- package/src/runtime/channel-readiness-types.ts +29 -0
- package/src/runtime/http-server.ts +53 -27
- package/src/runtime/http-types.ts +3 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-routes.ts +67 -21
- package/src/runtime/run-orchestrator.ts +35 -2
- package/src/tools/assets/materialize.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -0
- package/src/tools/credentials/vault.ts +1 -1
- package/src/tools/execution-target.ts +11 -1
- package/src/tools/network/web-search.ts +1 -1
- package/src/tools/types.ts +2 -0
- package/src/twitter/router.ts +1 -1
- package/src/util/platform.ts +35 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
2
|
+
import { rmSync, mkdtempSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const testDir = mkdtempSync(join(tmpdir(), 'entity-search-test-'));
|
|
7
|
+
|
|
8
|
+
mock.module('../util/platform.js', () => ({
|
|
9
|
+
getDataDir: () => testDir,
|
|
10
|
+
isMacOS: () => process.platform === 'darwin',
|
|
11
|
+
isLinux: () => process.platform === 'linux',
|
|
12
|
+
isWindows: () => process.platform === 'win32',
|
|
13
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
14
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
15
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
16
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
17
|
+
ensureDataDir: () => {},
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
mock.module('../util/logger.js', () => ({
|
|
21
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
22
|
+
get: () => () => {},
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
27
|
+
import { upsertEntity, upsertEntityRelation } from '../memory/entity-extractor.js';
|
|
28
|
+
import { findNeighborEntities, findMatchedEntities, getEntityLinkedItemCandidates, collectTypedNeighbors, intersectReachable } from '../memory/search/entity.js';
|
|
29
|
+
import { memoryItems, memoryItemEntities } from '../memory/schema.js';
|
|
30
|
+
import { Database } from 'bun:sqlite';
|
|
31
|
+
|
|
32
|
+
function getRawDb(): Database {
|
|
33
|
+
return (getDb() as unknown as { $client: Database }).$client;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function insertMemoryItem(id: string, opts?: { scopeId?: string; status?: string; invalidAt?: number | null }) {
|
|
37
|
+
const db = getDb();
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
db.insert(memoryItems).values({
|
|
40
|
+
id,
|
|
41
|
+
kind: 'fact',
|
|
42
|
+
subject: `Subject ${id}`,
|
|
43
|
+
statement: `Statement for ${id}`,
|
|
44
|
+
confidence: 0.9,
|
|
45
|
+
importance: 0.5,
|
|
46
|
+
status: opts?.status ?? 'active',
|
|
47
|
+
invalidAt: opts?.invalidAt ?? null,
|
|
48
|
+
scopeId: opts?.scopeId ?? 'default',
|
|
49
|
+
fingerprint: `fp-${id}`,
|
|
50
|
+
firstSeenAt: now,
|
|
51
|
+
lastSeenAt: now,
|
|
52
|
+
accessCount: 0,
|
|
53
|
+
lastUsedAt: null,
|
|
54
|
+
verificationState: 'assistant_inferred',
|
|
55
|
+
}).run();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function linkItemToEntity(memoryItemId: string, entityId: string) {
|
|
59
|
+
const db = getDb();
|
|
60
|
+
db.insert(memoryItemEntities).values({ memoryItemId, entityId }).run();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function insertMemoryItemSource(memoryItemId: string, messageId: string) {
|
|
64
|
+
// Bypass foreign key checks since we don't need actual message rows for these tests
|
|
65
|
+
const raw = getRawDb();
|
|
66
|
+
raw.run('PRAGMA foreign_keys = OFF');
|
|
67
|
+
raw.run(
|
|
68
|
+
`INSERT INTO memory_item_sources (memory_item_id, message_id, evidence, created_at)
|
|
69
|
+
VALUES (?, ?, NULL, ?)`,
|
|
70
|
+
[memoryItemId, messageId, Date.now()],
|
|
71
|
+
);
|
|
72
|
+
raw.run('PRAGMA foreign_keys = ON');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('entity search', () => {
|
|
76
|
+
beforeAll(() => {
|
|
77
|
+
initializeDb();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
beforeEach(() => {
|
|
81
|
+
const db = getDb();
|
|
82
|
+
db.run('DELETE FROM memory_item_sources');
|
|
83
|
+
db.run('DELETE FROM memory_item_entities');
|
|
84
|
+
db.run('DELETE FROM memory_entity_relations');
|
|
85
|
+
db.run('DELETE FROM memory_entities');
|
|
86
|
+
db.run('DELETE FROM memory_items');
|
|
87
|
+
db.run('DELETE FROM memory_checkpoints');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterAll(() => {
|
|
91
|
+
resetDb();
|
|
92
|
+
try {
|
|
93
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
94
|
+
} catch {
|
|
95
|
+
// best effort cleanup
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── findNeighborEntities ───────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe('findNeighborEntities', () => {
|
|
102
|
+
test('returns empty for empty seed list', () => {
|
|
103
|
+
const result = findNeighborEntities([], { maxEdges: 10, maxNeighborEntities: 10, maxDepth: 3 });
|
|
104
|
+
expect(result.neighborEntityIds).toEqual([]);
|
|
105
|
+
expect(result.traversedEdgeCount).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('returns empty when no edges exist', () => {
|
|
109
|
+
const entityId = upsertEntity({ name: 'Lonely', type: 'concept', aliases: [] });
|
|
110
|
+
const result = findNeighborEntities([entityId], { maxEdges: 10, maxNeighborEntities: 10, maxDepth: 3 });
|
|
111
|
+
expect(result.neighborEntityIds).toEqual([]);
|
|
112
|
+
expect(result.traversedEdgeCount).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('single-hop: seed A has edge to B returns [B]', () => {
|
|
116
|
+
const a = upsertEntity({ name: 'Alpha', type: 'project', aliases: [] });
|
|
117
|
+
const b = upsertEntity({ name: 'Beta', type: 'tool', aliases: [] });
|
|
118
|
+
|
|
119
|
+
upsertEntityRelation({
|
|
120
|
+
sourceEntityId: a,
|
|
121
|
+
targetEntityId: b,
|
|
122
|
+
relation: 'uses',
|
|
123
|
+
evidence: 'Alpha uses Beta',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result = findNeighborEntities([a], { maxEdges: 10, maxNeighborEntities: 10, maxDepth: 3 });
|
|
127
|
+
expect(result.neighborEntityIds).toContain(b);
|
|
128
|
+
expect(result.neighborEntityIds).toHaveLength(1);
|
|
129
|
+
expect(result.traversedEdgeCount).toBeGreaterThan(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('multi-hop: A->B->C with maxDepth=2 returns [B, C]', () => {
|
|
133
|
+
const a = upsertEntity({ name: 'NodeA', type: 'concept', aliases: [] });
|
|
134
|
+
const b = upsertEntity({ name: 'NodeB', type: 'concept', aliases: [] });
|
|
135
|
+
const c = upsertEntity({ name: 'NodeC', type: 'concept', aliases: [] });
|
|
136
|
+
|
|
137
|
+
upsertEntityRelation({
|
|
138
|
+
sourceEntityId: a,
|
|
139
|
+
targetEntityId: b,
|
|
140
|
+
relation: 'related_to',
|
|
141
|
+
evidence: null,
|
|
142
|
+
});
|
|
143
|
+
upsertEntityRelation({
|
|
144
|
+
sourceEntityId: b,
|
|
145
|
+
targetEntityId: c,
|
|
146
|
+
relation: 'related_to',
|
|
147
|
+
evidence: null,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const result = findNeighborEntities([a], { maxEdges: 20, maxNeighborEntities: 10, maxDepth: 2 });
|
|
151
|
+
expect(result.neighborEntityIds).toContain(b);
|
|
152
|
+
expect(result.neighborEntityIds).toContain(c);
|
|
153
|
+
expect(result.neighborEntityIds).toHaveLength(2);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('cycle detection: A->B->A returns [B], does not loop', () => {
|
|
157
|
+
const a = upsertEntity({ name: 'CycleA', type: 'person', aliases: [] });
|
|
158
|
+
const b = upsertEntity({ name: 'CycleB', type: 'person', aliases: [] });
|
|
159
|
+
|
|
160
|
+
upsertEntityRelation({
|
|
161
|
+
sourceEntityId: a,
|
|
162
|
+
targetEntityId: b,
|
|
163
|
+
relation: 'collaborates_with',
|
|
164
|
+
evidence: null,
|
|
165
|
+
});
|
|
166
|
+
upsertEntityRelation({
|
|
167
|
+
sourceEntityId: b,
|
|
168
|
+
targetEntityId: a,
|
|
169
|
+
relation: 'collaborates_with',
|
|
170
|
+
evidence: null,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const result = findNeighborEntities([a], { maxEdges: 20, maxNeighborEntities: 10, maxDepth: 5 });
|
|
174
|
+
expect(result.neighborEntityIds).toEqual([b]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('maxDepth=1 stops after first hop', () => {
|
|
178
|
+
const a = upsertEntity({ name: 'DepthA', type: 'concept', aliases: [] });
|
|
179
|
+
const b = upsertEntity({ name: 'DepthB', type: 'concept', aliases: [] });
|
|
180
|
+
const c = upsertEntity({ name: 'DepthC', type: 'concept', aliases: [] });
|
|
181
|
+
|
|
182
|
+
upsertEntityRelation({
|
|
183
|
+
sourceEntityId: a,
|
|
184
|
+
targetEntityId: b,
|
|
185
|
+
relation: 'depends_on',
|
|
186
|
+
evidence: null,
|
|
187
|
+
});
|
|
188
|
+
upsertEntityRelation({
|
|
189
|
+
sourceEntityId: b,
|
|
190
|
+
targetEntityId: c,
|
|
191
|
+
relation: 'depends_on',
|
|
192
|
+
evidence: null,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const result = findNeighborEntities([a], { maxEdges: 20, maxNeighborEntities: 10, maxDepth: 1 });
|
|
196
|
+
expect(result.neighborEntityIds).toContain(b);
|
|
197
|
+
expect(result.neighborEntityIds).not.toContain(c);
|
|
198
|
+
expect(result.neighborEntityIds).toHaveLength(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('maxEdges budget exhaustion stops traversal', () => {
|
|
202
|
+
const a = upsertEntity({ name: 'BudgetA', type: 'concept', aliases: [] });
|
|
203
|
+
const b = upsertEntity({ name: 'BudgetB', type: 'concept', aliases: [] });
|
|
204
|
+
const c = upsertEntity({ name: 'BudgetC', type: 'concept', aliases: [] });
|
|
205
|
+
const d = upsertEntity({ name: 'BudgetD', type: 'concept', aliases: [] });
|
|
206
|
+
|
|
207
|
+
upsertEntityRelation({ sourceEntityId: a, targetEntityId: b, relation: 'related_to', evidence: null });
|
|
208
|
+
upsertEntityRelation({ sourceEntityId: a, targetEntityId: c, relation: 'related_to', evidence: null });
|
|
209
|
+
upsertEntityRelation({ sourceEntityId: b, targetEntityId: d, relation: 'related_to', evidence: null });
|
|
210
|
+
|
|
211
|
+
// Allow only 1 edge total, so BFS can't explore much
|
|
212
|
+
const result = findNeighborEntities([a], { maxEdges: 1, maxNeighborEntities: 10, maxDepth: 3 });
|
|
213
|
+
expect(result.traversedEdgeCount).toBeLessThanOrEqual(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('maxNeighborEntities cap limits result size', () => {
|
|
217
|
+
const seed = upsertEntity({ name: 'HubNode', type: 'concept', aliases: [] });
|
|
218
|
+
const neighbors: string[] = [];
|
|
219
|
+
for (let i = 0; i < 5; i++) {
|
|
220
|
+
const n = upsertEntity({ name: `Spoke${i}`, type: 'concept', aliases: [] });
|
|
221
|
+
neighbors.push(n);
|
|
222
|
+
upsertEntityRelation({ sourceEntityId: seed, targetEntityId: n, relation: 'related_to', evidence: null });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const result = findNeighborEntities([seed], { maxEdges: 20, maxNeighborEntities: 2, maxDepth: 3 });
|
|
226
|
+
expect(result.neighborEntityIds).toHaveLength(2);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('bidirectional: edge from X->A discovers X from seed [A]', () => {
|
|
230
|
+
const a = upsertEntity({ name: 'TargetNode', type: 'tool', aliases: [] });
|
|
231
|
+
const x = upsertEntity({ name: 'SourceNode', type: 'tool', aliases: [] });
|
|
232
|
+
|
|
233
|
+
upsertEntityRelation({
|
|
234
|
+
sourceEntityId: x,
|
|
235
|
+
targetEntityId: a,
|
|
236
|
+
relation: 'uses',
|
|
237
|
+
evidence: null,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const result = findNeighborEntities([a], { maxEdges: 10, maxNeighborEntities: 10, maxDepth: 3 });
|
|
241
|
+
expect(result.neighborEntityIds).toContain(x);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('multiple seeds: [A, B] discovers neighbors of both', () => {
|
|
245
|
+
const a = upsertEntity({ name: 'SeedA', type: 'project', aliases: [] });
|
|
246
|
+
const b = upsertEntity({ name: 'SeedB', type: 'project', aliases: [] });
|
|
247
|
+
const na = upsertEntity({ name: 'NeighborOfA', type: 'tool', aliases: [] });
|
|
248
|
+
const nb = upsertEntity({ name: 'NeighborOfB', type: 'tool', aliases: [] });
|
|
249
|
+
|
|
250
|
+
upsertEntityRelation({ sourceEntityId: a, targetEntityId: na, relation: 'uses', evidence: null });
|
|
251
|
+
upsertEntityRelation({ sourceEntityId: b, targetEntityId: nb, relation: 'uses', evidence: null });
|
|
252
|
+
|
|
253
|
+
const result = findNeighborEntities([a, b], { maxEdges: 20, maxNeighborEntities: 10, maxDepth: 3 });
|
|
254
|
+
expect(result.neighborEntityIds).toContain(na);
|
|
255
|
+
expect(result.neighborEntityIds).toContain(nb);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('relationTypes filter: only follows specified edge types', () => {
|
|
259
|
+
const idA = upsertEntity({ name: 'PersonAlpha', type: 'person', aliases: [] });
|
|
260
|
+
const idB = upsertEntity({ name: 'ToolBeta', type: 'tool', aliases: [] });
|
|
261
|
+
const idC = upsertEntity({ name: 'ProjectGamma', type: 'project', aliases: [] });
|
|
262
|
+
|
|
263
|
+
upsertEntityRelation({ sourceEntityId: idA, targetEntityId: idB, relation: 'uses' });
|
|
264
|
+
upsertEntityRelation({ sourceEntityId: idA, targetEntityId: idC, relation: 'works_on' });
|
|
265
|
+
|
|
266
|
+
const result = findNeighborEntities([idA], {
|
|
267
|
+
maxEdges: 10,
|
|
268
|
+
maxNeighborEntities: 10,
|
|
269
|
+
maxDepth: 1,
|
|
270
|
+
relationTypes: ['uses'],
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(result.neighborEntityIds).toContain(idB);
|
|
274
|
+
expect(result.neighborEntityIds).not.toContain(idC);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('relationTypes filter: omitting filter follows all edge types', () => {
|
|
278
|
+
const idA = upsertEntity({ name: 'PersonDelta', type: 'person', aliases: [] });
|
|
279
|
+
const idB = upsertEntity({ name: 'ToolEpsilon', type: 'tool', aliases: [] });
|
|
280
|
+
const idC = upsertEntity({ name: 'ProjectZeta', type: 'project', aliases: [] });
|
|
281
|
+
|
|
282
|
+
upsertEntityRelation({ sourceEntityId: idA, targetEntityId: idB, relation: 'uses' });
|
|
283
|
+
upsertEntityRelation({ sourceEntityId: idA, targetEntityId: idC, relation: 'works_on' });
|
|
284
|
+
|
|
285
|
+
const result = findNeighborEntities([idA], {
|
|
286
|
+
maxEdges: 10,
|
|
287
|
+
maxNeighborEntities: 10,
|
|
288
|
+
maxDepth: 1,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(result.neighborEntityIds).toContain(idB);
|
|
292
|
+
expect(result.neighborEntityIds).toContain(idC);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('entityTypes filter: only returns entities of specified types', () => {
|
|
296
|
+
const idPerson = upsertEntity({ name: 'PersonEta', type: 'person', aliases: [] });
|
|
297
|
+
const idProject = upsertEntity({ name: 'ProjectTheta', type: 'project', aliases: [] });
|
|
298
|
+
const idTool = upsertEntity({ name: 'ToolIota', type: 'tool', aliases: [] });
|
|
299
|
+
|
|
300
|
+
upsertEntityRelation({ sourceEntityId: idPerson, targetEntityId: idProject, relation: 'works_on' });
|
|
301
|
+
upsertEntityRelation({ sourceEntityId: idPerson, targetEntityId: idTool, relation: 'uses' });
|
|
302
|
+
|
|
303
|
+
const result = findNeighborEntities([idPerson], {
|
|
304
|
+
maxEdges: 10,
|
|
305
|
+
maxNeighborEntities: 10,
|
|
306
|
+
maxDepth: 1,
|
|
307
|
+
entityTypes: ['project'],
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(result.neighborEntityIds).toContain(idProject);
|
|
311
|
+
expect(result.neighborEntityIds).not.toContain(idTool);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('entityTypes filter: omitting filter returns all entity types', () => {
|
|
315
|
+
const idPerson = upsertEntity({ name: 'PersonKappa', type: 'person', aliases: [] });
|
|
316
|
+
const idProject = upsertEntity({ name: 'ProjectLambda', type: 'project', aliases: [] });
|
|
317
|
+
const idTool = upsertEntity({ name: 'ToolMu', type: 'tool', aliases: [] });
|
|
318
|
+
|
|
319
|
+
upsertEntityRelation({ sourceEntityId: idPerson, targetEntityId: idProject, relation: 'works_on' });
|
|
320
|
+
upsertEntityRelation({ sourceEntityId: idPerson, targetEntityId: idTool, relation: 'uses' });
|
|
321
|
+
|
|
322
|
+
const result = findNeighborEntities([idPerson], {
|
|
323
|
+
maxEdges: 10,
|
|
324
|
+
maxNeighborEntities: 10,
|
|
325
|
+
maxDepth: 1,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect(result.neighborEntityIds).toContain(idProject);
|
|
329
|
+
expect(result.neighborEntityIds).toContain(idTool);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('neighborDepths tracks BFS depth for each neighbor', () => {
|
|
333
|
+
// A -> B -> C (chain)
|
|
334
|
+
const idA = upsertEntity({ name: 'DepthAlpha', type: 'person', aliases: [] });
|
|
335
|
+
const idB = upsertEntity({ name: 'DepthBeta', type: 'tool', aliases: [] });
|
|
336
|
+
const idC = upsertEntity({ name: 'DepthGamma', type: 'project', aliases: [] });
|
|
337
|
+
|
|
338
|
+
upsertEntityRelation({ sourceEntityId: idA, targetEntityId: idB, relation: 'uses' });
|
|
339
|
+
upsertEntityRelation({ sourceEntityId: idB, targetEntityId: idC, relation: 'depends_on' });
|
|
340
|
+
|
|
341
|
+
const result = findNeighborEntities([idA], {
|
|
342
|
+
maxEdges: 10,
|
|
343
|
+
maxNeighborEntities: 10,
|
|
344
|
+
maxDepth: 2,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(result.neighborEntityIds).toContain(idB);
|
|
348
|
+
expect(result.neighborEntityIds).toContain(idC);
|
|
349
|
+
expect(result.neighborDepths.get(idB)).toBe(1);
|
|
350
|
+
expect(result.neighborDepths.get(idC)).toBe(2);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('neighborDepths is empty when no neighbors found', () => {
|
|
354
|
+
const idA = upsertEntity({ name: 'DepthDelta', type: 'person', aliases: [] });
|
|
355
|
+
const result = findNeighborEntities([idA], {
|
|
356
|
+
maxEdges: 10,
|
|
357
|
+
maxNeighborEntities: 10,
|
|
358
|
+
maxDepth: 1,
|
|
359
|
+
});
|
|
360
|
+
expect(result.neighborDepths.size).toBe(0);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ── findMatchedEntities ────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
describe('findMatchedEntities', () => {
|
|
367
|
+
test('exact canonical name match', () => {
|
|
368
|
+
const entityId = upsertEntity({ name: 'Qdrant', type: 'tool', aliases: [] });
|
|
369
|
+
const results = findMatchedEntities('Qdrant', 10);
|
|
370
|
+
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
371
|
+
expect(results.some((r) => r.id === entityId)).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('alias match', () => {
|
|
375
|
+
const entityId = upsertEntity({ name: 'Visual Studio Code', type: 'tool', aliases: ['vscode', 'VS Code'] });
|
|
376
|
+
const results = findMatchedEntities('vscode', 10);
|
|
377
|
+
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
378
|
+
expect(results.some((r) => r.id === entityId)).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test('multi-word entity name match (full query)', () => {
|
|
382
|
+
const entityId = upsertEntity({ name: 'Visual Studio Code', type: 'tool', aliases: [] });
|
|
383
|
+
const results = findMatchedEntities('Visual Studio Code', 10);
|
|
384
|
+
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
385
|
+
expect(results.some((r) => r.id === entityId)).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('tokens < 3 chars are ignored but full query still matches', () => {
|
|
389
|
+
// "VS" has only 2 chars, so it is filtered as a token.
|
|
390
|
+
// But the full query "VS" is still matched against entity names and aliases.
|
|
391
|
+
const entityId = upsertEntity({ name: 'VS', type: 'tool', aliases: [] });
|
|
392
|
+
const results = findMatchedEntities('VS', 10);
|
|
393
|
+
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
394
|
+
expect(results.some((r) => r.id === entityId)).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('returns empty for no matches', () => {
|
|
398
|
+
upsertEntity({ name: 'Existing', type: 'concept', aliases: [] });
|
|
399
|
+
const results = findMatchedEntities('NonExistentEntity', 10);
|
|
400
|
+
expect(results).toEqual([]);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('respects maxMatches limit', () => {
|
|
404
|
+
// Insert entities directly via raw DB to avoid upsertEntity dedup logic.
|
|
405
|
+
// All share the alias "gadget" so they all match the same query.
|
|
406
|
+
const raw = getRawDb();
|
|
407
|
+
const now = Date.now();
|
|
408
|
+
for (let i = 0; i < 5; i++) {
|
|
409
|
+
const id = crypto.randomUUID();
|
|
410
|
+
raw.run(
|
|
411
|
+
`INSERT INTO memory_entities (id, name, type, aliases, description, first_seen_at, last_seen_at, mention_count)
|
|
412
|
+
VALUES (?, ?, 'concept', '["gadget"]', NULL, ?, ?, 1)`,
|
|
413
|
+
[id, `Gadget${i}`, now, now],
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const results = findMatchedEntities('gadget', 2);
|
|
418
|
+
expect(results.length).toBeLessThanOrEqual(2);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// ── getEntityLinkedItemCandidates ──────────────────────────────────
|
|
423
|
+
|
|
424
|
+
describe('getEntityLinkedItemCandidates', () => {
|
|
425
|
+
test('returns items linked to given entity IDs', () => {
|
|
426
|
+
const entityId = upsertEntity({ name: 'LinkedEntity', type: 'project', aliases: [] });
|
|
427
|
+
insertMemoryItem('item-linked-1');
|
|
428
|
+
linkItemToEntity('item-linked-1', entityId);
|
|
429
|
+
|
|
430
|
+
const candidates = getEntityLinkedItemCandidates([entityId], {
|
|
431
|
+
source: 'entity_direct',
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
expect(candidates.length).toBe(1);
|
|
435
|
+
expect(candidates[0].id).toBe('item-linked-1');
|
|
436
|
+
expect(candidates[0].source).toBe('entity_direct');
|
|
437
|
+
expect(candidates[0].type).toBe('item');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('excludes items from excluded message IDs', () => {
|
|
441
|
+
const entityId = upsertEntity({ name: 'ExcludeEntity', type: 'tool', aliases: [] });
|
|
442
|
+
|
|
443
|
+
insertMemoryItem('item-excl-1');
|
|
444
|
+
linkItemToEntity('item-excl-1', entityId);
|
|
445
|
+
// Source the item from a message we will exclude
|
|
446
|
+
insertMemoryItemSource('item-excl-1', 'msg-to-exclude');
|
|
447
|
+
|
|
448
|
+
insertMemoryItem('item-excl-2');
|
|
449
|
+
linkItemToEntity('item-excl-2', entityId);
|
|
450
|
+
// Source from a non-excluded message
|
|
451
|
+
insertMemoryItemSource('item-excl-2', 'msg-ok');
|
|
452
|
+
|
|
453
|
+
const candidates = getEntityLinkedItemCandidates([entityId], {
|
|
454
|
+
source: 'entity_direct',
|
|
455
|
+
excludedMessageIds: ['msg-to-exclude'],
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(candidates.some((c) => c.id === 'item-excl-1')).toBe(false);
|
|
459
|
+
expect(candidates.some((c) => c.id === 'item-excl-2')).toBe(true);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('returns empty for entity IDs with no linked items', () => {
|
|
463
|
+
const entityId = upsertEntity({ name: 'NoItems', type: 'concept', aliases: [] });
|
|
464
|
+
|
|
465
|
+
const candidates = getEntityLinkedItemCandidates([entityId], {
|
|
466
|
+
source: 'entity_direct',
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
expect(candidates).toEqual([]);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// ── collectTypedNeighbors ────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
describe('collectTypedNeighbors', () => {
|
|
476
|
+
test('multi-step: person -> projects -> tools', () => {
|
|
477
|
+
const person = upsertEntity({ name: 'StepPerson1', type: 'person', aliases: [] });
|
|
478
|
+
const project1 = upsertEntity({ name: 'StepProject1', type: 'project', aliases: [] });
|
|
479
|
+
const project2 = upsertEntity({ name: 'StepProject2', type: 'project', aliases: [] });
|
|
480
|
+
const tool1 = upsertEntity({ name: 'StepTool1', type: 'tool', aliases: [] });
|
|
481
|
+
const tool2 = upsertEntity({ name: 'StepTool2', type: 'tool', aliases: [] });
|
|
482
|
+
const tool3 = upsertEntity({ name: 'StepTool3', type: 'tool', aliases: [] });
|
|
483
|
+
|
|
484
|
+
// person works_on project1 and project2
|
|
485
|
+
upsertEntityRelation({ sourceEntityId: person, targetEntityId: project1, relation: 'works_on' });
|
|
486
|
+
upsertEntityRelation({ sourceEntityId: person, targetEntityId: project2, relation: 'works_on' });
|
|
487
|
+
// project1 uses tool1 and tool2
|
|
488
|
+
upsertEntityRelation({ sourceEntityId: project1, targetEntityId: tool1, relation: 'uses' });
|
|
489
|
+
upsertEntityRelation({ sourceEntityId: project1, targetEntityId: tool2, relation: 'uses' });
|
|
490
|
+
// project2 uses tool2 and tool3
|
|
491
|
+
upsertEntityRelation({ sourceEntityId: project2, targetEntityId: tool2, relation: 'uses' });
|
|
492
|
+
upsertEntityRelation({ sourceEntityId: project2, targetEntityId: tool3, relation: 'uses' });
|
|
493
|
+
|
|
494
|
+
const result = collectTypedNeighbors(
|
|
495
|
+
[person],
|
|
496
|
+
[
|
|
497
|
+
{ relationTypes: ['works_on'], entityTypes: ['project'] },
|
|
498
|
+
{ relationTypes: ['uses'], entityTypes: ['tool'] },
|
|
499
|
+
],
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
expect(result).toContain(tool1);
|
|
503
|
+
expect(result).toContain(tool2);
|
|
504
|
+
expect(result).toContain(tool3);
|
|
505
|
+
// Should NOT include person or projects in final result
|
|
506
|
+
expect(result).not.toContain(person);
|
|
507
|
+
expect(result).not.toContain(project1);
|
|
508
|
+
expect(result).not.toContain(project2);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test('returns empty for empty seeds', () => {
|
|
512
|
+
const result = collectTypedNeighbors([], [{ relationTypes: ['uses'] }]);
|
|
513
|
+
expect(result).toEqual([]);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test('returns empty for empty steps', () => {
|
|
517
|
+
const person = upsertEntity({ name: 'StepPerson2', type: 'person', aliases: [] });
|
|
518
|
+
const result = collectTypedNeighbors([person], []);
|
|
519
|
+
expect(result).toEqual([]);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
test('single step equivalent to filtered BFS', () => {
|
|
523
|
+
const person = upsertEntity({ name: 'StepPerson3', type: 'person', aliases: [] });
|
|
524
|
+
const tool = upsertEntity({ name: 'StepTool4', type: 'tool', aliases: [] });
|
|
525
|
+
const project = upsertEntity({ name: 'StepProject3', type: 'project', aliases: [] });
|
|
526
|
+
|
|
527
|
+
upsertEntityRelation({ sourceEntityId: person, targetEntityId: tool, relation: 'uses' });
|
|
528
|
+
upsertEntityRelation({ sourceEntityId: person, targetEntityId: project, relation: 'works_on' });
|
|
529
|
+
|
|
530
|
+
const result = collectTypedNeighbors(
|
|
531
|
+
[person],
|
|
532
|
+
[{ relationTypes: ['uses'], entityTypes: ['tool'] }],
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
expect(result).toContain(tool);
|
|
536
|
+
expect(result).not.toContain(project);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('chain breaks when intermediate step finds no matches', () => {
|
|
540
|
+
const person = upsertEntity({ name: 'StepPerson4', type: 'person', aliases: [] });
|
|
541
|
+
// person has no edges
|
|
542
|
+
const result = collectTypedNeighbors(
|
|
543
|
+
[person],
|
|
544
|
+
[
|
|
545
|
+
{ relationTypes: ['works_on'], entityTypes: ['project'] },
|
|
546
|
+
{ relationTypes: ['uses'], entityTypes: ['tool'] },
|
|
547
|
+
],
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
expect(result).toEqual([]);
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// ── intersectReachable ───────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
describe('intersectReachable', () => {
|
|
557
|
+
test('finds shared projects between two people', () => {
|
|
558
|
+
const alice = upsertEntity({ name: 'IntersectAlice', type: 'person', aliases: [] });
|
|
559
|
+
const bob = upsertEntity({ name: 'IntersectBob', type: 'person', aliases: [] });
|
|
560
|
+
const sharedProject = upsertEntity({ name: 'IntersectSharedProj', type: 'project', aliases: [] });
|
|
561
|
+
const aliceOnly = upsertEntity({ name: 'IntersectAliceProj', type: 'project', aliases: [] });
|
|
562
|
+
const bobOnly = upsertEntity({ name: 'IntersectBobProj', type: 'project', aliases: [] });
|
|
563
|
+
|
|
564
|
+
upsertEntityRelation({ sourceEntityId: alice, targetEntityId: sharedProject, relation: 'works_on' });
|
|
565
|
+
upsertEntityRelation({ sourceEntityId: alice, targetEntityId: aliceOnly, relation: 'works_on' });
|
|
566
|
+
upsertEntityRelation({ sourceEntityId: bob, targetEntityId: sharedProject, relation: 'works_on' });
|
|
567
|
+
upsertEntityRelation({ sourceEntityId: bob, targetEntityId: bobOnly, relation: 'works_on' });
|
|
568
|
+
|
|
569
|
+
const result = intersectReachable([
|
|
570
|
+
{ seedEntityIds: [alice], steps: [{ relationTypes: ['works_on'], entityTypes: ['project'] }] },
|
|
571
|
+
{ seedEntityIds: [bob], steps: [{ relationTypes: ['works_on'], entityTypes: ['project'] }] },
|
|
572
|
+
]);
|
|
573
|
+
|
|
574
|
+
expect(result).toContain(sharedProject);
|
|
575
|
+
expect(result).not.toContain(aliceOnly);
|
|
576
|
+
expect(result).not.toContain(bobOnly);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('returns empty when no overlap', () => {
|
|
580
|
+
const alice = upsertEntity({ name: 'IntersectAlice2', type: 'person', aliases: [] });
|
|
581
|
+
const bob = upsertEntity({ name: 'IntersectBob2', type: 'person', aliases: [] });
|
|
582
|
+
const projA = upsertEntity({ name: 'IntersectProjA', type: 'project', aliases: [] });
|
|
583
|
+
const projB = upsertEntity({ name: 'IntersectProjB', type: 'project', aliases: [] });
|
|
584
|
+
|
|
585
|
+
upsertEntityRelation({ sourceEntityId: alice, targetEntityId: projA, relation: 'works_on' });
|
|
586
|
+
upsertEntityRelation({ sourceEntityId: bob, targetEntityId: projB, relation: 'works_on' });
|
|
587
|
+
|
|
588
|
+
const result = intersectReachable([
|
|
589
|
+
{ seedEntityIds: [alice], steps: [{ relationTypes: ['works_on'], entityTypes: ['project'] }] },
|
|
590
|
+
{ seedEntityIds: [bob], steps: [{ relationTypes: ['works_on'], entityTypes: ['project'] }] },
|
|
591
|
+
]);
|
|
592
|
+
|
|
593
|
+
expect(result).toEqual([]);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test('single query is equivalent to collectTypedNeighbors', () => {
|
|
597
|
+
const person = upsertEntity({ name: 'IntersectSingle', type: 'person', aliases: [] });
|
|
598
|
+
const tool = upsertEntity({ name: 'IntersectTool', type: 'tool', aliases: [] });
|
|
599
|
+
|
|
600
|
+
upsertEntityRelation({ sourceEntityId: person, targetEntityId: tool, relation: 'uses' });
|
|
601
|
+
|
|
602
|
+
const result = intersectReachable([
|
|
603
|
+
{ seedEntityIds: [person], steps: [{ relationTypes: ['uses'], entityTypes: ['tool'] }] },
|
|
604
|
+
]);
|
|
605
|
+
|
|
606
|
+
expect(result).toContain(tool);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test('returns empty for empty queries array', () => {
|
|
610
|
+
const result = intersectReachable([]);
|
|
611
|
+
expect(result).toEqual([]);
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
});
|