@vellumai/assistant 0.3.3 → 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.
Files changed (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -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
+ });
@@ -71,6 +71,9 @@ mock.module('../config/loader.js', () => ({
71
71
  ingress: {
72
72
  publicBaseUrl: 'https://test.example.com',
73
73
  },
74
+ sms: {
75
+ phoneNumber: '+15550001111',
76
+ },
74
77
  }),
75
78
  getConfig: () => ({
76
79
  model: 'test',
@@ -82,6 +85,9 @@ mock.module('../config/loader.js', () => ({
82
85
  ingress: {
83
86
  publicBaseUrl: 'https://test.example.com',
84
87
  },
88
+ sms: {
89
+ phoneNumber: '+15550001111',
90
+ },
85
91
  }),
86
92
  invalidateConfigCache: () => {},
87
93
  }));
@@ -96,21 +102,21 @@ mock.module('../calls/twilio-provider.js', () => ({
96
102
  },
97
103
  }));
98
104
 
99
- // Mock Twilio config
100
- mock.module('../calls/twilio-config.js', () => ({
101
- getTwilioConfig: () => ({
102
- accountSid: 'AC_test',
103
- authToken: 'test_token',
104
- phoneNumber: '+15550001111',
105
- webhookBaseUrl: 'https://test.example.com',
106
- wssBaseUrl: 'wss://test.example.com',
107
- }),
108
- }));
105
+ const secureKeyStore: Record<string, string | undefined> = {
106
+ 'credential:twilio:account_sid': 'AC_test',
107
+ 'credential:twilio:auth_token': 'test_token',
108
+ 'credential:twilio:phone_number': '+15550001111',
109
+ };
109
110
 
110
111
  mock.module('../security/secure-keys.js', () => ({
111
- getSecureKey: () => null,
112
- setSecureKey: () => true,
113
- deleteSecureKey: () => {},
112
+ getSecureKey: (key: string) => secureKeyStore[key] ?? null,
113
+ setSecureKey: (key: string, value: string) => {
114
+ secureKeyStore[key] = value;
115
+ return true;
116
+ },
117
+ deleteSecureKey: (key: string) => {
118
+ delete secureKeyStore[key];
119
+ },
114
120
  }));
115
121
 
116
122
  // NOTE: Do NOT mock '../inbound/public-ingress-urls.js' here.