@vellumai/assistant 0.4.49 → 0.4.50

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 (239) hide show
  1. package/ARCHITECTURE.md +24 -33
  2. package/README.md +3 -3
  3. package/docs/architecture/memory.md +180 -119
  4. package/package.json +2 -2
  5. package/src/__tests__/agent-loop.test.ts +3 -1
  6. package/src/__tests__/anthropic-provider.test.ts +114 -23
  7. package/src/__tests__/approval-cascade.test.ts +1 -15
  8. package/src/__tests__/approval-routes-http.test.ts +2 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +0 -23
  10. package/src/__tests__/canonical-guardian-store.test.ts +95 -0
  11. package/src/__tests__/checker.test.ts +13 -0
  12. package/src/__tests__/config-schema.test.ts +1 -68
  13. package/src/__tests__/context-memory-e2e.test.ts +11 -100
  14. package/src/__tests__/conversation-routes-guardian-reply.test.ts +8 -0
  15. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  16. package/src/__tests__/credential-security-e2e.test.ts +1 -0
  17. package/src/__tests__/credential-vault-unit.test.ts +4 -0
  18. package/src/__tests__/credential-vault.test.ts +13 -1
  19. package/src/__tests__/cu-unified-flow.test.ts +532 -0
  20. package/src/__tests__/date-context.test.ts +93 -77
  21. package/src/__tests__/deterministic-verification-control-plane.test.ts +64 -0
  22. package/src/__tests__/guardian-routing-invariants.test.ts +93 -0
  23. package/src/__tests__/history-repair.test.ts +245 -0
  24. package/src/__tests__/host-cu-proxy.test.ts +165 -3
  25. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  26. package/src/__tests__/invite-redemption-service.test.ts +65 -1
  27. package/src/__tests__/keychain-broker-client.test.ts +4 -4
  28. package/src/__tests__/memory-context-benchmark.benchmark.test.ts +56 -18
  29. package/src/__tests__/memory-lifecycle-e2e.test.ts +244 -387
  30. package/src/__tests__/memory-recall-quality.test.ts +244 -407
  31. package/src/__tests__/memory-regressions.experimental.test.ts +126 -101
  32. package/src/__tests__/memory-regressions.test.ts +477 -2841
  33. package/src/__tests__/memory-retrieval.benchmark.test.ts +33 -150
  34. package/src/__tests__/memory-upsert-concurrency.test.ts +5 -244
  35. package/src/__tests__/mime-builder.test.ts +28 -0
  36. package/src/__tests__/native-web-search.test.ts +1 -0
  37. package/src/__tests__/oauth-cli.test.ts +572 -5
  38. package/src/__tests__/oauth-store.test.ts +120 -6
  39. package/src/__tests__/qdrant-collection-migration.test.ts +53 -8
  40. package/src/__tests__/registry.test.ts +0 -1
  41. package/src/__tests__/relay-server.test.ts +46 -1
  42. package/src/__tests__/schedule-tools.test.ts +32 -0
  43. package/src/__tests__/script-proxy-certs.test.ts +1 -1
  44. package/src/__tests__/secret-onetime-send.test.ts +1 -0
  45. package/src/__tests__/secure-keys.test.ts +7 -2
  46. package/src/__tests__/send-endpoint-busy.test.ts +3 -0
  47. package/src/__tests__/session-abort-tool-results.test.ts +1 -14
  48. package/src/__tests__/session-agent-loop-overflow.test.ts +1583 -0
  49. package/src/__tests__/session-agent-loop.test.ts +19 -15
  50. package/src/__tests__/session-confirmation-signals.test.ts +1 -15
  51. package/src/__tests__/session-error.test.ts +124 -2
  52. package/src/__tests__/session-history-web-search.test.ts +918 -0
  53. package/src/__tests__/session-pre-run-repair.test.ts +1 -14
  54. package/src/__tests__/session-provider-retry-repair.test.ts +25 -28
  55. package/src/__tests__/session-queue.test.ts +37 -27
  56. package/src/__tests__/session-runtime-assembly.test.ts +54 -0
  57. package/src/__tests__/session-slash-known.test.ts +1 -15
  58. package/src/__tests__/session-slash-queue.test.ts +1 -15
  59. package/src/__tests__/session-slash-unknown.test.ts +1 -15
  60. package/src/__tests__/session-workspace-cache-state.test.ts +3 -33
  61. package/src/__tests__/session-workspace-injection.test.ts +3 -37
  62. package/src/__tests__/session-workspace-tool-tracking.test.ts +3 -37
  63. package/src/__tests__/skills-install-extract.test.ts +93 -0
  64. package/src/__tests__/skillssh-registry.test.ts +451 -0
  65. package/src/__tests__/trust-store.test.ts +15 -0
  66. package/src/__tests__/voice-invite-redemption.test.ts +32 -1
  67. package/src/agent/ax-tree-compaction.test.ts +51 -0
  68. package/src/agent/loop.ts +39 -12
  69. package/src/approvals/AGENTS.md +1 -1
  70. package/src/approvals/guardian-request-resolvers.ts +14 -2
  71. package/src/bundler/compiler-tools.ts +66 -2
  72. package/src/calls/call-domain.ts +132 -0
  73. package/src/calls/call-store.ts +6 -0
  74. package/src/calls/relay-server.ts +43 -5
  75. package/src/calls/relay-setup-router.ts +17 -1
  76. package/src/calls/twilio-config.ts +1 -1
  77. package/src/calls/types.ts +3 -1
  78. package/src/cli/commands/doctor.ts +4 -3
  79. package/src/cli/commands/mcp.ts +46 -59
  80. package/src/cli/commands/memory.ts +16 -165
  81. package/src/cli/commands/oauth/apps.ts +31 -2
  82. package/src/cli/commands/oauth/connections.ts +431 -97
  83. package/src/cli/commands/oauth/providers.ts +15 -1
  84. package/src/cli/commands/sessions.ts +5 -2
  85. package/src/cli/commands/skills.ts +173 -1
  86. package/src/cli/http-client.ts +0 -20
  87. package/src/cli/main-screen.tsx +2 -2
  88. package/src/cli/program.ts +5 -6
  89. package/src/cli.ts +4 -10
  90. package/src/config/bundled-skills/computer-use/TOOLS.json +1 -1
  91. package/src/config/bundled-skills/computer-use/tools/computer-use-observe.ts +12 -0
  92. package/src/config/bundled-tool-registry.ts +2 -5
  93. package/src/config/schema.ts +1 -12
  94. package/src/config/schemas/memory-lifecycle.ts +0 -9
  95. package/src/config/schemas/memory-processing.ts +0 -180
  96. package/src/config/schemas/memory-retrieval.ts +32 -104
  97. package/src/config/schemas/memory.ts +0 -10
  98. package/src/config/types.ts +0 -4
  99. package/src/context/window-manager.ts +4 -1
  100. package/src/daemon/config-watcher.ts +61 -3
  101. package/src/daemon/daemon-control.ts +1 -1
  102. package/src/daemon/date-context.ts +114 -31
  103. package/src/daemon/handlers/sessions.ts +18 -13
  104. package/src/daemon/handlers/skills.ts +20 -1
  105. package/src/daemon/history-repair.ts +72 -8
  106. package/src/daemon/host-cu-proxy.ts +55 -26
  107. package/src/daemon/lifecycle.ts +31 -3
  108. package/src/daemon/mcp-reload-service.ts +2 -2
  109. package/src/daemon/message-types/computer-use.ts +1 -12
  110. package/src/daemon/message-types/memory.ts +4 -16
  111. package/src/daemon/message-types/messages.ts +1 -0
  112. package/src/daemon/message-types/sessions.ts +4 -0
  113. package/src/daemon/server.ts +12 -1
  114. package/src/daemon/session-agent-loop-handlers.ts +38 -0
  115. package/src/daemon/session-agent-loop.ts +334 -48
  116. package/src/daemon/session-error.ts +89 -6
  117. package/src/daemon/session-history.ts +17 -7
  118. package/src/daemon/session-media-retry.ts +6 -2
  119. package/src/daemon/session-memory.ts +69 -149
  120. package/src/daemon/session-process.ts +10 -1
  121. package/src/daemon/session-runtime-assembly.ts +49 -19
  122. package/src/daemon/session-surfaces.ts +4 -1
  123. package/src/daemon/session-tool-setup.ts +7 -1
  124. package/src/daemon/session.ts +12 -2
  125. package/src/instrument.ts +61 -1
  126. package/src/memory/admin.ts +2 -191
  127. package/src/memory/canonical-guardian-store.ts +38 -2
  128. package/src/memory/conversation-crud.ts +0 -33
  129. package/src/memory/conversation-queries.ts +22 -3
  130. package/src/memory/db-init.ts +28 -0
  131. package/src/memory/embedding-backend.ts +84 -8
  132. package/src/memory/embedding-types.ts +9 -1
  133. package/src/memory/indexer.ts +7 -46
  134. package/src/memory/items-extractor.ts +274 -76
  135. package/src/memory/job-handlers/backfill.ts +2 -127
  136. package/src/memory/job-handlers/cleanup.ts +2 -16
  137. package/src/memory/job-handlers/extraction.ts +2 -138
  138. package/src/memory/job-handlers/index-maintenance.ts +1 -6
  139. package/src/memory/job-handlers/summarization.ts +3 -148
  140. package/src/memory/job-utils.ts +21 -59
  141. package/src/memory/jobs-store.ts +1 -159
  142. package/src/memory/jobs-worker.ts +9 -52
  143. package/src/memory/migrations/104-core-indexes.ts +3 -3
  144. package/src/memory/migrations/149-oauth-tables.ts +2 -0
  145. package/src/memory/migrations/150-oauth-apps-client-secret-path.ts +98 -0
  146. package/src/memory/migrations/151-oauth-providers-ping-url.ts +11 -0
  147. package/src/memory/migrations/152-memory-item-supersession.ts +44 -0
  148. package/src/memory/migrations/153-drop-entity-tables.ts +15 -0
  149. package/src/memory/migrations/154-drop-fts.ts +20 -0
  150. package/src/memory/migrations/155-drop-conflicts.ts +7 -0
  151. package/src/memory/migrations/156-call-session-invite-metadata.ts +24 -0
  152. package/src/memory/migrations/index.ts +7 -0
  153. package/src/memory/qdrant-client.ts +148 -51
  154. package/src/memory/raw-query.ts +1 -1
  155. package/src/memory/retriever.test.ts +294 -273
  156. package/src/memory/retriever.ts +421 -645
  157. package/src/memory/schema/calls.ts +2 -0
  158. package/src/memory/schema/memory-core.ts +3 -48
  159. package/src/memory/schema/oauth.ts +2 -0
  160. package/src/memory/search/formatting.ts +263 -176
  161. package/src/memory/search/lexical.ts +1 -254
  162. package/src/memory/search/ranking.ts +0 -455
  163. package/src/memory/search/semantic.ts +100 -14
  164. package/src/memory/search/staleness.ts +47 -0
  165. package/src/memory/search/tier-classifier.ts +21 -0
  166. package/src/memory/search/types.ts +15 -77
  167. package/src/memory/task-memory-cleanup.ts +4 -6
  168. package/src/messaging/providers/gmail/mime-builder.ts +17 -7
  169. package/src/oauth/byo-connection.test.ts +8 -1
  170. package/src/oauth/oauth-store.ts +113 -27
  171. package/src/oauth/seed-providers.ts +6 -0
  172. package/src/oauth/token-persistence.ts +11 -3
  173. package/src/permissions/defaults.ts +1 -0
  174. package/src/permissions/trust-store.ts +23 -1
  175. package/src/playbooks/playbook-compiler.ts +1 -1
  176. package/src/prompts/system-prompt.ts +18 -2
  177. package/src/providers/anthropic/client.ts +56 -126
  178. package/src/providers/types.ts +7 -1
  179. package/src/runtime/AGENTS.md +9 -0
  180. package/src/runtime/auth/route-policy.ts +6 -3
  181. package/src/runtime/guardian-reply-router.ts +24 -22
  182. package/src/runtime/http-server.ts +2 -2
  183. package/src/runtime/invite-redemption-service.ts +19 -1
  184. package/src/runtime/invite-service.ts +25 -0
  185. package/src/runtime/pending-interactions.ts +2 -2
  186. package/src/runtime/routes/brain-graph-routes.ts +10 -90
  187. package/src/runtime/routes/conversation-routes.ts +9 -1
  188. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -12
  189. package/src/runtime/routes/memory-item-routes.test.ts +754 -0
  190. package/src/runtime/routes/memory-item-routes.ts +503 -0
  191. package/src/runtime/routes/session-management-routes.ts +3 -3
  192. package/src/runtime/routes/settings-routes.ts +2 -2
  193. package/src/runtime/routes/trust-rules-routes.ts +14 -0
  194. package/src/runtime/routes/workspace-routes.ts +2 -1
  195. package/src/security/keychain-broker-client.ts +17 -4
  196. package/src/security/secure-keys.ts +25 -3
  197. package/src/security/token-manager.ts +36 -36
  198. package/src/skills/catalog-install.ts +74 -18
  199. package/src/skills/skillssh-registry.ts +503 -0
  200. package/src/tools/assets/search.ts +5 -1
  201. package/src/tools/computer-use/definitions.ts +0 -10
  202. package/src/tools/computer-use/registry.ts +1 -1
  203. package/src/tools/credentials/vault.ts +1 -3
  204. package/src/tools/memory/definitions.ts +4 -13
  205. package/src/tools/memory/handlers.test.ts +83 -103
  206. package/src/tools/memory/handlers.ts +50 -85
  207. package/src/tools/schedule/create.ts +8 -1
  208. package/src/tools/schedule/update.ts +8 -1
  209. package/src/tools/skills/load.ts +25 -2
  210. package/src/__tests__/clarification-resolver.test.ts +0 -193
  211. package/src/__tests__/conflict-intent-tokenization.test.ts +0 -160
  212. package/src/__tests__/conflict-policy.test.ts +0 -269
  213. package/src/__tests__/conflict-store.test.ts +0 -372
  214. package/src/__tests__/contradiction-checker.test.ts +0 -361
  215. package/src/__tests__/entity-extractor.test.ts +0 -211
  216. package/src/__tests__/entity-search.test.ts +0 -1117
  217. package/src/__tests__/profile-compiler.test.ts +0 -392
  218. package/src/__tests__/session-conflict-gate.test.ts +0 -1228
  219. package/src/__tests__/session-profile-injection.test.ts +0 -557
  220. package/src/config/bundled-skills/knowledge-graph/SKILL.md +0 -25
  221. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +0 -66
  222. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +0 -211
  223. package/src/daemon/session-conflict-gate.ts +0 -167
  224. package/src/daemon/session-dynamic-profile.ts +0 -77
  225. package/src/memory/clarification-resolver.ts +0 -417
  226. package/src/memory/conflict-intent.ts +0 -205
  227. package/src/memory/conflict-policy.ts +0 -127
  228. package/src/memory/conflict-store.ts +0 -410
  229. package/src/memory/contradiction-checker.ts +0 -508
  230. package/src/memory/entity-extractor.ts +0 -535
  231. package/src/memory/format-recall.ts +0 -47
  232. package/src/memory/fts-reconciler.ts +0 -165
  233. package/src/memory/job-handlers/conflict.ts +0 -200
  234. package/src/memory/profile-compiler.ts +0 -195
  235. package/src/memory/recall-cache.ts +0 -117
  236. package/src/memory/search/entity.ts +0 -535
  237. package/src/memory/search/query-expansion.test.ts +0 -70
  238. package/src/memory/search/query-expansion.ts +0 -118
  239. package/src/runtime/routes/mcp-routes.ts +0 -20
@@ -1,1117 +0,0 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
- import {
5
- afterAll,
6
- beforeAll,
7
- beforeEach,
8
- describe,
9
- expect,
10
- mock,
11
- test,
12
- } from "bun:test";
13
-
14
- const testDir = mkdtempSync(join(tmpdir(), "entity-search-test-"));
15
-
16
- mock.module("../util/platform.js", () => ({
17
- getDataDir: () => testDir,
18
- isMacOS: () => process.platform === "darwin",
19
- isLinux: () => process.platform === "linux",
20
- isWindows: () => process.platform === "win32",
21
- getPidPath: () => join(testDir, "test.pid"),
22
- getDbPath: () => join(testDir, "test.db"),
23
- getLogPath: () => join(testDir, "test.log"),
24
- ensureDataDir: () => {},
25
- }));
26
-
27
- mock.module("../util/logger.js", () => ({
28
- getLogger: () =>
29
- new Proxy({} as Record<string, unknown>, {
30
- get: () => () => {},
31
- }),
32
- }));
33
-
34
- import { Database } from "bun:sqlite";
35
-
36
- import { getDb, initializeDb, resetDb } from "../memory/db.js";
37
- import {
38
- upsertEntity,
39
- upsertEntityRelation,
40
- } from "../memory/entity-extractor.js";
41
- import { memoryItemEntities, memoryItems } from "../memory/schema.js";
42
- import {
43
- collectTypedNeighbors,
44
- findMatchedEntities,
45
- findNeighborEntities,
46
- getEntityLinkedItemCandidates,
47
- intersectReachable,
48
- } from "../memory/search/entity.js";
49
-
50
- function getRawDb(): Database {
51
- return (getDb() as unknown as { $client: Database }).$client;
52
- }
53
-
54
- function insertMemoryItem(
55
- id: string,
56
- opts?: { scopeId?: string; status?: string; invalidAt?: number | null },
57
- ) {
58
- const db = getDb();
59
- const now = Date.now();
60
- db.insert(memoryItems)
61
- .values({
62
- id,
63
- kind: "fact",
64
- subject: `Subject ${id}`,
65
- statement: `Statement for ${id}`,
66
- confidence: 0.9,
67
- importance: 0.5,
68
- status: opts?.status ?? "active",
69
- invalidAt: opts?.invalidAt ?? null,
70
- scopeId: opts?.scopeId ?? "default",
71
- fingerprint: `fp-${id}`,
72
- firstSeenAt: now,
73
- lastSeenAt: now,
74
- accessCount: 0,
75
- lastUsedAt: null,
76
- verificationState: "assistant_inferred",
77
- })
78
- .run();
79
- }
80
-
81
- function linkItemToEntity(memoryItemId: string, entityId: string) {
82
- const db = getDb();
83
- db.insert(memoryItemEntities).values({ memoryItemId, entityId }).run();
84
- }
85
-
86
- function insertMemoryItemSource(memoryItemId: string, messageId: string) {
87
- // Bypass foreign key checks since we don't need actual message rows for these tests
88
- const raw = getRawDb();
89
- raw.run("PRAGMA foreign_keys = OFF");
90
- raw.run(
91
- `INSERT INTO memory_item_sources (memory_item_id, message_id, evidence, created_at)
92
- VALUES (?, ?, NULL, ?)`,
93
- [memoryItemId, messageId, Date.now()],
94
- );
95
- raw.run("PRAGMA foreign_keys = ON");
96
- }
97
-
98
- describe("entity search", () => {
99
- beforeAll(() => {
100
- initializeDb();
101
- });
102
-
103
- beforeEach(() => {
104
- const db = getDb();
105
- db.run("DELETE FROM memory_item_sources");
106
- db.run("DELETE FROM memory_item_entities");
107
- db.run("DELETE FROM memory_entity_relations");
108
- db.run("DELETE FROM memory_entities");
109
- db.run("DELETE FROM memory_items");
110
- db.run("DELETE FROM memory_checkpoints");
111
- });
112
-
113
- afterAll(() => {
114
- resetDb();
115
- try {
116
- rmSync(testDir, { recursive: true, force: true });
117
- } catch {
118
- // best effort cleanup
119
- }
120
- });
121
-
122
- // ── findNeighborEntities ───────────────────────────────────────────
123
-
124
- describe("findNeighborEntities", () => {
125
- test("returns empty for empty seed list", () => {
126
- const result = findNeighborEntities([], {
127
- maxEdges: 10,
128
- maxNeighborEntities: 10,
129
- maxDepth: 3,
130
- });
131
- expect(result.neighborEntityIds).toEqual([]);
132
- expect(result.traversedEdgeCount).toBe(0);
133
- });
134
-
135
- test("returns empty when no edges exist", () => {
136
- const entityId = upsertEntity({
137
- name: "Lonely",
138
- type: "concept",
139
- aliases: [],
140
- });
141
- const result = findNeighborEntities([entityId], {
142
- maxEdges: 10,
143
- maxNeighborEntities: 10,
144
- maxDepth: 3,
145
- });
146
- expect(result.neighborEntityIds).toEqual([]);
147
- expect(result.traversedEdgeCount).toBe(0);
148
- });
149
-
150
- test("single-hop: seed A has edge to B returns [B]", () => {
151
- const a = upsertEntity({ name: "Alpha", type: "project", aliases: [] });
152
- const b = upsertEntity({ name: "Beta", type: "tool", aliases: [] });
153
-
154
- upsertEntityRelation({
155
- sourceEntityId: a,
156
- targetEntityId: b,
157
- relation: "uses",
158
- evidence: "Alpha uses Beta",
159
- });
160
-
161
- const result = findNeighborEntities([a], {
162
- maxEdges: 10,
163
- maxNeighborEntities: 10,
164
- maxDepth: 3,
165
- });
166
- expect(result.neighborEntityIds).toContain(b);
167
- expect(result.neighborEntityIds).toHaveLength(1);
168
- expect(result.traversedEdgeCount).toBeGreaterThan(0);
169
- });
170
-
171
- test("multi-hop: A->B->C with maxDepth=2 returns [B, C]", () => {
172
- const a = upsertEntity({ name: "NodeA", type: "concept", aliases: [] });
173
- const b = upsertEntity({ name: "NodeB", type: "concept", aliases: [] });
174
- const c = upsertEntity({ name: "NodeC", type: "concept", aliases: [] });
175
-
176
- upsertEntityRelation({
177
- sourceEntityId: a,
178
- targetEntityId: b,
179
- relation: "related_to",
180
- evidence: null,
181
- });
182
- upsertEntityRelation({
183
- sourceEntityId: b,
184
- targetEntityId: c,
185
- relation: "related_to",
186
- evidence: null,
187
- });
188
-
189
- const result = findNeighborEntities([a], {
190
- maxEdges: 20,
191
- maxNeighborEntities: 10,
192
- maxDepth: 2,
193
- });
194
- expect(result.neighborEntityIds).toContain(b);
195
- expect(result.neighborEntityIds).toContain(c);
196
- expect(result.neighborEntityIds).toHaveLength(2);
197
- });
198
-
199
- test("cycle detection: A->B->A returns [B], does not loop", () => {
200
- const a = upsertEntity({ name: "CycleA", type: "person", aliases: [] });
201
- const b = upsertEntity({ name: "CycleB", type: "person", aliases: [] });
202
-
203
- upsertEntityRelation({
204
- sourceEntityId: a,
205
- targetEntityId: b,
206
- relation: "collaborates_with",
207
- evidence: null,
208
- });
209
- upsertEntityRelation({
210
- sourceEntityId: b,
211
- targetEntityId: a,
212
- relation: "collaborates_with",
213
- evidence: null,
214
- });
215
-
216
- const result = findNeighborEntities([a], {
217
- maxEdges: 20,
218
- maxNeighborEntities: 10,
219
- maxDepth: 5,
220
- });
221
- expect(result.neighborEntityIds).toEqual([b]);
222
- });
223
-
224
- test("maxDepth=1 stops after first hop", () => {
225
- const a = upsertEntity({ name: "DepthA", type: "concept", aliases: [] });
226
- const b = upsertEntity({ name: "DepthB", type: "concept", aliases: [] });
227
- const c = upsertEntity({ name: "DepthC", type: "concept", aliases: [] });
228
-
229
- upsertEntityRelation({
230
- sourceEntityId: a,
231
- targetEntityId: b,
232
- relation: "depends_on",
233
- evidence: null,
234
- });
235
- upsertEntityRelation({
236
- sourceEntityId: b,
237
- targetEntityId: c,
238
- relation: "depends_on",
239
- evidence: null,
240
- });
241
-
242
- const result = findNeighborEntities([a], {
243
- maxEdges: 20,
244
- maxNeighborEntities: 10,
245
- maxDepth: 1,
246
- });
247
- expect(result.neighborEntityIds).toContain(b);
248
- expect(result.neighborEntityIds).not.toContain(c);
249
- expect(result.neighborEntityIds).toHaveLength(1);
250
- });
251
-
252
- test("maxEdges budget exhaustion stops traversal", () => {
253
- const a = upsertEntity({ name: "BudgetA", type: "concept", aliases: [] });
254
- const b = upsertEntity({ name: "BudgetB", type: "concept", aliases: [] });
255
- const c = upsertEntity({ name: "BudgetC", type: "concept", aliases: [] });
256
- const d = upsertEntity({ name: "BudgetD", type: "concept", aliases: [] });
257
-
258
- upsertEntityRelation({
259
- sourceEntityId: a,
260
- targetEntityId: b,
261
- relation: "related_to",
262
- evidence: null,
263
- });
264
- upsertEntityRelation({
265
- sourceEntityId: a,
266
- targetEntityId: c,
267
- relation: "related_to",
268
- evidence: null,
269
- });
270
- upsertEntityRelation({
271
- sourceEntityId: b,
272
- targetEntityId: d,
273
- relation: "related_to",
274
- evidence: null,
275
- });
276
-
277
- // Allow only 1 edge total, so BFS can't explore much
278
- const result = findNeighborEntities([a], {
279
- maxEdges: 1,
280
- maxNeighborEntities: 10,
281
- maxDepth: 3,
282
- });
283
- expect(result.traversedEdgeCount).toBeLessThanOrEqual(1);
284
- });
285
-
286
- test("maxNeighborEntities cap limits result size", () => {
287
- const seed = upsertEntity({
288
- name: "HubNode",
289
- type: "concept",
290
- aliases: [],
291
- });
292
- const neighbors: string[] = [];
293
- for (let i = 0; i < 5; i++) {
294
- const n = upsertEntity({
295
- name: `Spoke${i}`,
296
- type: "concept",
297
- aliases: [],
298
- });
299
- neighbors.push(n);
300
- upsertEntityRelation({
301
- sourceEntityId: seed,
302
- targetEntityId: n,
303
- relation: "related_to",
304
- evidence: null,
305
- });
306
- }
307
-
308
- const result = findNeighborEntities([seed], {
309
- maxEdges: 20,
310
- maxNeighborEntities: 2,
311
- maxDepth: 3,
312
- });
313
- expect(result.neighborEntityIds).toHaveLength(2);
314
- });
315
-
316
- test("bidirectional: edge from X->A discovers X from seed [A]", () => {
317
- const a = upsertEntity({ name: "TargetNode", type: "tool", aliases: [] });
318
- const x = upsertEntity({ name: "SourceNode", type: "tool", aliases: [] });
319
-
320
- upsertEntityRelation({
321
- sourceEntityId: x,
322
- targetEntityId: a,
323
- relation: "uses",
324
- evidence: null,
325
- });
326
-
327
- const result = findNeighborEntities([a], {
328
- maxEdges: 10,
329
- maxNeighborEntities: 10,
330
- maxDepth: 3,
331
- });
332
- expect(result.neighborEntityIds).toContain(x);
333
- });
334
-
335
- test("multiple seeds: [A, B] discovers neighbors of both", () => {
336
- const a = upsertEntity({ name: "SeedA", type: "project", aliases: [] });
337
- const b = upsertEntity({ name: "SeedB", type: "project", aliases: [] });
338
- const na = upsertEntity({
339
- name: "NeighborOfA",
340
- type: "tool",
341
- aliases: [],
342
- });
343
- const nb = upsertEntity({
344
- name: "NeighborOfB",
345
- type: "tool",
346
- aliases: [],
347
- });
348
-
349
- upsertEntityRelation({
350
- sourceEntityId: a,
351
- targetEntityId: na,
352
- relation: "uses",
353
- evidence: null,
354
- });
355
- upsertEntityRelation({
356
- sourceEntityId: b,
357
- targetEntityId: nb,
358
- relation: "uses",
359
- evidence: null,
360
- });
361
-
362
- const result = findNeighborEntities([a, b], {
363
- maxEdges: 20,
364
- maxNeighborEntities: 10,
365
- maxDepth: 3,
366
- });
367
- expect(result.neighborEntityIds).toContain(na);
368
- expect(result.neighborEntityIds).toContain(nb);
369
- });
370
-
371
- test("relationTypes filter: only follows specified edge types", () => {
372
- const idA = upsertEntity({
373
- name: "PersonAlpha",
374
- type: "person",
375
- aliases: [],
376
- });
377
- const idB = upsertEntity({ name: "ToolBeta", type: "tool", aliases: [] });
378
- const idC = upsertEntity({
379
- name: "ProjectGamma",
380
- type: "project",
381
- aliases: [],
382
- });
383
-
384
- upsertEntityRelation({
385
- sourceEntityId: idA,
386
- targetEntityId: idB,
387
- relation: "uses",
388
- });
389
- upsertEntityRelation({
390
- sourceEntityId: idA,
391
- targetEntityId: idC,
392
- relation: "works_on",
393
- });
394
-
395
- const result = findNeighborEntities([idA], {
396
- maxEdges: 10,
397
- maxNeighborEntities: 10,
398
- maxDepth: 1,
399
- relationTypes: ["uses"],
400
- });
401
-
402
- expect(result.neighborEntityIds).toContain(idB);
403
- expect(result.neighborEntityIds).not.toContain(idC);
404
- });
405
-
406
- test("relationTypes filter: omitting filter follows all edge types", () => {
407
- const idA = upsertEntity({
408
- name: "PersonDelta",
409
- type: "person",
410
- aliases: [],
411
- });
412
- const idB = upsertEntity({
413
- name: "ToolEpsilon",
414
- type: "tool",
415
- aliases: [],
416
- });
417
- const idC = upsertEntity({
418
- name: "ProjectZeta",
419
- type: "project",
420
- aliases: [],
421
- });
422
-
423
- upsertEntityRelation({
424
- sourceEntityId: idA,
425
- targetEntityId: idB,
426
- relation: "uses",
427
- });
428
- upsertEntityRelation({
429
- sourceEntityId: idA,
430
- targetEntityId: idC,
431
- relation: "works_on",
432
- });
433
-
434
- const result = findNeighborEntities([idA], {
435
- maxEdges: 10,
436
- maxNeighborEntities: 10,
437
- maxDepth: 1,
438
- });
439
-
440
- expect(result.neighborEntityIds).toContain(idB);
441
- expect(result.neighborEntityIds).toContain(idC);
442
- });
443
-
444
- test("entityTypes filter: only returns entities of specified types", () => {
445
- const idPerson = upsertEntity({
446
- name: "PersonEta",
447
- type: "person",
448
- aliases: [],
449
- });
450
- const idProject = upsertEntity({
451
- name: "ProjectTheta",
452
- type: "project",
453
- aliases: [],
454
- });
455
- const idTool = upsertEntity({
456
- name: "ToolIota",
457
- type: "tool",
458
- aliases: [],
459
- });
460
-
461
- upsertEntityRelation({
462
- sourceEntityId: idPerson,
463
- targetEntityId: idProject,
464
- relation: "works_on",
465
- });
466
- upsertEntityRelation({
467
- sourceEntityId: idPerson,
468
- targetEntityId: idTool,
469
- relation: "uses",
470
- });
471
-
472
- const result = findNeighborEntities([idPerson], {
473
- maxEdges: 10,
474
- maxNeighborEntities: 10,
475
- maxDepth: 1,
476
- entityTypes: ["project"],
477
- });
478
-
479
- expect(result.neighborEntityIds).toContain(idProject);
480
- expect(result.neighborEntityIds).not.toContain(idTool);
481
- });
482
-
483
- test("entityTypes filter: omitting filter returns all entity types", () => {
484
- const idPerson = upsertEntity({
485
- name: "PersonKappa",
486
- type: "person",
487
- aliases: [],
488
- });
489
- const idProject = upsertEntity({
490
- name: "ProjectLambda",
491
- type: "project",
492
- aliases: [],
493
- });
494
- const idTool = upsertEntity({
495
- name: "ToolMu",
496
- type: "tool",
497
- aliases: [],
498
- });
499
-
500
- upsertEntityRelation({
501
- sourceEntityId: idPerson,
502
- targetEntityId: idProject,
503
- relation: "works_on",
504
- });
505
- upsertEntityRelation({
506
- sourceEntityId: idPerson,
507
- targetEntityId: idTool,
508
- relation: "uses",
509
- });
510
-
511
- const result = findNeighborEntities([idPerson], {
512
- maxEdges: 10,
513
- maxNeighborEntities: 10,
514
- maxDepth: 1,
515
- });
516
-
517
- expect(result.neighborEntityIds).toContain(idProject);
518
- expect(result.neighborEntityIds).toContain(idTool);
519
- });
520
-
521
- test("neighborDepths tracks BFS depth for each neighbor", () => {
522
- // A -> B -> C (chain)
523
- const idA = upsertEntity({
524
- name: "DepthAlpha",
525
- type: "person",
526
- aliases: [],
527
- });
528
- const idB = upsertEntity({
529
- name: "DepthBeta",
530
- type: "tool",
531
- aliases: [],
532
- });
533
- const idC = upsertEntity({
534
- name: "DepthGamma",
535
- type: "project",
536
- aliases: [],
537
- });
538
-
539
- upsertEntityRelation({
540
- sourceEntityId: idA,
541
- targetEntityId: idB,
542
- relation: "uses",
543
- });
544
- upsertEntityRelation({
545
- sourceEntityId: idB,
546
- targetEntityId: idC,
547
- relation: "depends_on",
548
- });
549
-
550
- const result = findNeighborEntities([idA], {
551
- maxEdges: 10,
552
- maxNeighborEntities: 10,
553
- maxDepth: 2,
554
- });
555
-
556
- expect(result.neighborEntityIds).toContain(idB);
557
- expect(result.neighborEntityIds).toContain(idC);
558
- expect(result.neighborDepths.get(idB)).toBe(1);
559
- expect(result.neighborDepths.get(idC)).toBe(2);
560
- });
561
-
562
- test("neighborDepths is empty when no neighbors found", () => {
563
- const idA = upsertEntity({
564
- name: "DepthDelta",
565
- type: "person",
566
- aliases: [],
567
- });
568
- const result = findNeighborEntities([idA], {
569
- maxEdges: 10,
570
- maxNeighborEntities: 10,
571
- maxDepth: 1,
572
- });
573
- expect(result.neighborDepths.size).toBe(0);
574
- });
575
-
576
- test("deep chain: maxDepth caps traversal on a long linear chain", () => {
577
- // Build a linear chain of 20 entities: N0 -> N1 -> ... -> N19
578
- const chain: string[] = [];
579
- for (let i = 0; i < 20; i++) {
580
- chain.push(
581
- upsertEntity({ name: `DeepChain${i}`, type: "concept", aliases: [] }),
582
- );
583
- }
584
- for (let i = 0; i < chain.length - 1; i++) {
585
- upsertEntityRelation({
586
- sourceEntityId: chain[i],
587
- targetEntityId: chain[i + 1],
588
- relation: "related_to",
589
- evidence: null,
590
- });
591
- }
592
-
593
- const maxDepth = 3;
594
- const result = findNeighborEntities([chain[0]], {
595
- maxEdges: 200,
596
- maxNeighborEntities: 200,
597
- maxDepth,
598
- });
599
-
600
- // Should find exactly nodes at depth 1..3 (chain[1], chain[2], chain[3])
601
- expect(result.neighborEntityIds).toHaveLength(maxDepth);
602
- for (let d = 1; d <= maxDepth; d++) {
603
- expect(result.neighborEntityIds).toContain(chain[d]);
604
- expect(result.neighborDepths.get(chain[d])).toBe(d);
605
- }
606
- // Nodes beyond maxDepth should not be reached
607
- for (let i = maxDepth + 1; i < chain.length; i++) {
608
- expect(result.neighborEntityIds).not.toContain(chain[i]);
609
- }
610
- });
611
-
612
- test("large cycle: traversal terminates on a fully-connected ring", () => {
613
- // Build a ring: N0 -> N1 -> ... -> N9 -> N0
614
- const ringSize = 10;
615
- const ring: string[] = [];
616
- for (let i = 0; i < ringSize; i++) {
617
- ring.push(
618
- upsertEntity({ name: `Ring${i}`, type: "concept", aliases: [] }),
619
- );
620
- }
621
- for (let i = 0; i < ringSize; i++) {
622
- upsertEntityRelation({
623
- sourceEntityId: ring[i],
624
- targetEntityId: ring[(i + 1) % ringSize],
625
- relation: "related_to",
626
- evidence: null,
627
- });
628
- }
629
-
630
- // With maxDepth high enough to go around the ring multiple times if
631
- // cycle detection were broken, the visited set must prevent revisiting.
632
- const result = findNeighborEntities([ring[0]], {
633
- maxEdges: 500,
634
- maxNeighborEntities: 500,
635
- maxDepth: 20,
636
- });
637
-
638
- // Should discover exactly ringSize - 1 neighbors (all except the seed)
639
- expect(result.neighborEntityIds).toHaveLength(ringSize - 1);
640
- for (let i = 1; i < ringSize; i++) {
641
- expect(result.neighborEntityIds).toContain(ring[i]);
642
- }
643
- });
644
-
645
- test("dense cyclic graph: traversal terminates with multiple cycles and back-edges", () => {
646
- // Build a graph where every node connects to multiple others with back-edges
647
- const nodes: string[] = [];
648
- for (let i = 0; i < 8; i++) {
649
- nodes.push(
650
- upsertEntity({ name: `Dense${i}`, type: "concept", aliases: [] }),
651
- );
652
- }
653
- // Create a mesh: each node connects to the next 2 nodes (wrapping)
654
- for (let i = 0; i < nodes.length; i++) {
655
- for (let offset = 1; offset <= 2; offset++) {
656
- upsertEntityRelation({
657
- sourceEntityId: nodes[i],
658
- targetEntityId: nodes[(i + offset) % nodes.length],
659
- relation: "related_to",
660
- evidence: null,
661
- });
662
- }
663
- }
664
-
665
- const result = findNeighborEntities([nodes[0]], {
666
- maxEdges: 500,
667
- maxNeighborEntities: 500,
668
- maxDepth: 10,
669
- });
670
-
671
- // All non-seed nodes should be reachable, and traversal must terminate
672
- expect(result.neighborEntityIds).toHaveLength(nodes.length - 1);
673
- // No duplicate IDs in the result
674
- expect(new Set(result.neighborEntityIds).size).toBe(
675
- result.neighborEntityIds.length,
676
- );
677
- });
678
- });
679
-
680
- // ── findMatchedEntities ────────────────────────────────────────────
681
-
682
- describe("findMatchedEntities", () => {
683
- test("exact canonical name match", () => {
684
- const entityId = upsertEntity({
685
- name: "Qdrant",
686
- type: "tool",
687
- aliases: [],
688
- });
689
- const results = findMatchedEntities("Qdrant", 10);
690
- expect(results.length).toBeGreaterThanOrEqual(1);
691
- expect(results.some((r) => r.id === entityId)).toBe(true);
692
- });
693
-
694
- test("alias match", () => {
695
- const entityId = upsertEntity({
696
- name: "Visual Studio Code",
697
- type: "tool",
698
- aliases: ["vscode", "VS Code"],
699
- });
700
- const results = findMatchedEntities("vscode", 10);
701
- expect(results.length).toBeGreaterThanOrEqual(1);
702
- expect(results.some((r) => r.id === entityId)).toBe(true);
703
- });
704
-
705
- test("multi-word entity name match (full query)", () => {
706
- const entityId = upsertEntity({
707
- name: "Visual Studio Code",
708
- type: "tool",
709
- aliases: [],
710
- });
711
- const results = findMatchedEntities("Visual Studio Code", 10);
712
- expect(results.length).toBeGreaterThanOrEqual(1);
713
- expect(results.some((r) => r.id === entityId)).toBe(true);
714
- });
715
-
716
- test("tokens < 3 chars are ignored but full query still matches", () => {
717
- // "VS" has only 2 chars, so it is filtered as a token.
718
- // But the full query "VS" is still matched against entity names and aliases.
719
- const entityId = upsertEntity({ name: "VS", type: "tool", aliases: [] });
720
- const results = findMatchedEntities("VS", 10);
721
- expect(results.length).toBeGreaterThanOrEqual(1);
722
- expect(results.some((r) => r.id === entityId)).toBe(true);
723
- });
724
-
725
- test("returns empty for no matches", () => {
726
- upsertEntity({ name: "Existing", type: "concept", aliases: [] });
727
- const results = findMatchedEntities("NonExistentEntity", 10);
728
- expect(results).toEqual([]);
729
- });
730
-
731
- test("respects maxMatches limit", () => {
732
- // Insert entities directly via raw DB to avoid upsertEntity dedup logic.
733
- // All share the alias "gadget" so they all match the same query.
734
- const raw = getRawDb();
735
- const now = Date.now();
736
- for (let i = 0; i < 5; i++) {
737
- const id = crypto.randomUUID();
738
- raw.run(
739
- `INSERT INTO memory_entities (id, name, type, aliases, description, first_seen_at, last_seen_at, mention_count)
740
- VALUES (?, ?, 'concept', '["gadget"]', NULL, ?, ?, 1)`,
741
- [id, `Gadget${i}`, now, now],
742
- );
743
- }
744
-
745
- const results = findMatchedEntities("gadget", 2);
746
- expect(results.length).toBeLessThanOrEqual(2);
747
- });
748
- });
749
-
750
- // ── getEntityLinkedItemCandidates ──────────────────────────────────
751
-
752
- describe("getEntityLinkedItemCandidates", () => {
753
- test("returns items linked to given entity IDs", () => {
754
- const entityId = upsertEntity({
755
- name: "LinkedEntity",
756
- type: "project",
757
- aliases: [],
758
- });
759
- insertMemoryItem("item-linked-1");
760
- linkItemToEntity("item-linked-1", entityId);
761
-
762
- const candidates = getEntityLinkedItemCandidates([entityId], {
763
- source: "entity_direct",
764
- });
765
-
766
- expect(candidates.length).toBe(1);
767
- expect(candidates[0].id).toBe("item-linked-1");
768
- expect(candidates[0].source).toBe("entity_direct");
769
- expect(candidates[0].type).toBe("item");
770
- });
771
-
772
- test("excludes items from excluded message IDs", () => {
773
- const entityId = upsertEntity({
774
- name: "ExcludeEntity",
775
- type: "tool",
776
- aliases: [],
777
- });
778
-
779
- insertMemoryItem("item-excl-1");
780
- linkItemToEntity("item-excl-1", entityId);
781
- // Source the item from a message we will exclude
782
- insertMemoryItemSource("item-excl-1", "msg-to-exclude");
783
-
784
- insertMemoryItem("item-excl-2");
785
- linkItemToEntity("item-excl-2", entityId);
786
- // Source from a non-excluded message
787
- insertMemoryItemSource("item-excl-2", "msg-ok");
788
-
789
- const candidates = getEntityLinkedItemCandidates([entityId], {
790
- source: "entity_direct",
791
- excludedMessageIds: ["msg-to-exclude"],
792
- });
793
-
794
- expect(candidates.some((c) => c.id === "item-excl-1")).toBe(false);
795
- expect(candidates.some((c) => c.id === "item-excl-2")).toBe(true);
796
- });
797
-
798
- test("returns empty for entity IDs with no linked items", () => {
799
- const entityId = upsertEntity({
800
- name: "NoItems",
801
- type: "concept",
802
- aliases: [],
803
- });
804
-
805
- const candidates = getEntityLinkedItemCandidates([entityId], {
806
- source: "entity_direct",
807
- });
808
-
809
- expect(candidates).toEqual([]);
810
- });
811
- });
812
-
813
- // ── collectTypedNeighbors ────────────────────────────────────────────
814
-
815
- describe("collectTypedNeighbors", () => {
816
- test("multi-step: person -> projects -> tools", () => {
817
- const person = upsertEntity({
818
- name: "StepPerson1",
819
- type: "person",
820
- aliases: [],
821
- });
822
- const project1 = upsertEntity({
823
- name: "StepProject1",
824
- type: "project",
825
- aliases: [],
826
- });
827
- const project2 = upsertEntity({
828
- name: "StepProject2",
829
- type: "project",
830
- aliases: [],
831
- });
832
- const tool1 = upsertEntity({
833
- name: "StepTool1",
834
- type: "tool",
835
- aliases: [],
836
- });
837
- const tool2 = upsertEntity({
838
- name: "StepTool2",
839
- type: "tool",
840
- aliases: [],
841
- });
842
- const tool3 = upsertEntity({
843
- name: "StepTool3",
844
- type: "tool",
845
- aliases: [],
846
- });
847
-
848
- // person works_on project1 and project2
849
- upsertEntityRelation({
850
- sourceEntityId: person,
851
- targetEntityId: project1,
852
- relation: "works_on",
853
- });
854
- upsertEntityRelation({
855
- sourceEntityId: person,
856
- targetEntityId: project2,
857
- relation: "works_on",
858
- });
859
- // project1 uses tool1 and tool2
860
- upsertEntityRelation({
861
- sourceEntityId: project1,
862
- targetEntityId: tool1,
863
- relation: "uses",
864
- });
865
- upsertEntityRelation({
866
- sourceEntityId: project1,
867
- targetEntityId: tool2,
868
- relation: "uses",
869
- });
870
- // project2 uses tool2 and tool3
871
- upsertEntityRelation({
872
- sourceEntityId: project2,
873
- targetEntityId: tool2,
874
- relation: "uses",
875
- });
876
- upsertEntityRelation({
877
- sourceEntityId: project2,
878
- targetEntityId: tool3,
879
- relation: "uses",
880
- });
881
-
882
- const result = collectTypedNeighbors(
883
- [person],
884
- [
885
- { relationTypes: ["works_on"], entityTypes: ["project"] },
886
- { relationTypes: ["uses"], entityTypes: ["tool"] },
887
- ],
888
- );
889
-
890
- expect(result).toContain(tool1);
891
- expect(result).toContain(tool2);
892
- expect(result).toContain(tool3);
893
- // Should NOT include person or projects in final result
894
- expect(result).not.toContain(person);
895
- expect(result).not.toContain(project1);
896
- expect(result).not.toContain(project2);
897
- });
898
-
899
- test("returns empty for empty seeds", () => {
900
- const result = collectTypedNeighbors([], [{ relationTypes: ["uses"] }]);
901
- expect(result).toEqual([]);
902
- });
903
-
904
- test("returns empty for empty steps", () => {
905
- const person = upsertEntity({
906
- name: "StepPerson2",
907
- type: "person",
908
- aliases: [],
909
- });
910
- const result = collectTypedNeighbors([person], []);
911
- expect(result).toEqual([]);
912
- });
913
-
914
- test("single step equivalent to filtered BFS", () => {
915
- const person = upsertEntity({
916
- name: "StepPerson3",
917
- type: "person",
918
- aliases: [],
919
- });
920
- const tool = upsertEntity({
921
- name: "StepTool4",
922
- type: "tool",
923
- aliases: [],
924
- });
925
- const project = upsertEntity({
926
- name: "StepProject3",
927
- type: "project",
928
- aliases: [],
929
- });
930
-
931
- upsertEntityRelation({
932
- sourceEntityId: person,
933
- targetEntityId: tool,
934
- relation: "uses",
935
- });
936
- upsertEntityRelation({
937
- sourceEntityId: person,
938
- targetEntityId: project,
939
- relation: "works_on",
940
- });
941
-
942
- const result = collectTypedNeighbors(
943
- [person],
944
- [{ relationTypes: ["uses"], entityTypes: ["tool"] }],
945
- );
946
-
947
- expect(result).toContain(tool);
948
- expect(result).not.toContain(project);
949
- });
950
-
951
- test("chain breaks when intermediate step finds no matches", () => {
952
- const person = upsertEntity({
953
- name: "StepPerson4",
954
- type: "person",
955
- aliases: [],
956
- });
957
- // person has no edges
958
- const result = collectTypedNeighbors(
959
- [person],
960
- [
961
- { relationTypes: ["works_on"], entityTypes: ["project"] },
962
- { relationTypes: ["uses"], entityTypes: ["tool"] },
963
- ],
964
- );
965
-
966
- expect(result).toEqual([]);
967
- });
968
- });
969
-
970
- // ── intersectReachable ───────────────────────────────────────────────
971
-
972
- describe("intersectReachable", () => {
973
- test("finds shared projects between two people", () => {
974
- const alice = upsertEntity({
975
- name: "IntersectAlice",
976
- type: "person",
977
- aliases: [],
978
- });
979
- const bob = upsertEntity({
980
- name: "IntersectBob",
981
- type: "person",
982
- aliases: [],
983
- });
984
- const sharedProject = upsertEntity({
985
- name: "IntersectSharedProj",
986
- type: "project",
987
- aliases: [],
988
- });
989
- const aliceOnly = upsertEntity({
990
- name: "IntersectAliceProj",
991
- type: "project",
992
- aliases: [],
993
- });
994
- const bobOnly = upsertEntity({
995
- name: "IntersectBobProj",
996
- type: "project",
997
- aliases: [],
998
- });
999
-
1000
- upsertEntityRelation({
1001
- sourceEntityId: alice,
1002
- targetEntityId: sharedProject,
1003
- relation: "works_on",
1004
- });
1005
- upsertEntityRelation({
1006
- sourceEntityId: alice,
1007
- targetEntityId: aliceOnly,
1008
- relation: "works_on",
1009
- });
1010
- upsertEntityRelation({
1011
- sourceEntityId: bob,
1012
- targetEntityId: sharedProject,
1013
- relation: "works_on",
1014
- });
1015
- upsertEntityRelation({
1016
- sourceEntityId: bob,
1017
- targetEntityId: bobOnly,
1018
- relation: "works_on",
1019
- });
1020
-
1021
- const result = intersectReachable([
1022
- {
1023
- seedEntityIds: [alice],
1024
- steps: [{ relationTypes: ["works_on"], entityTypes: ["project"] }],
1025
- },
1026
- {
1027
- seedEntityIds: [bob],
1028
- steps: [{ relationTypes: ["works_on"], entityTypes: ["project"] }],
1029
- },
1030
- ]);
1031
-
1032
- expect(result).toContain(sharedProject);
1033
- expect(result).not.toContain(aliceOnly);
1034
- expect(result).not.toContain(bobOnly);
1035
- });
1036
-
1037
- test("returns empty when no overlap", () => {
1038
- const alice = upsertEntity({
1039
- name: "IntersectAlice2",
1040
- type: "person",
1041
- aliases: [],
1042
- });
1043
- const bob = upsertEntity({
1044
- name: "IntersectBob2",
1045
- type: "person",
1046
- aliases: [],
1047
- });
1048
- const projA = upsertEntity({
1049
- name: "IntersectProjA",
1050
- type: "project",
1051
- aliases: [],
1052
- });
1053
- const projB = upsertEntity({
1054
- name: "IntersectProjB",
1055
- type: "project",
1056
- aliases: [],
1057
- });
1058
-
1059
- upsertEntityRelation({
1060
- sourceEntityId: alice,
1061
- targetEntityId: projA,
1062
- relation: "works_on",
1063
- });
1064
- upsertEntityRelation({
1065
- sourceEntityId: bob,
1066
- targetEntityId: projB,
1067
- relation: "works_on",
1068
- });
1069
-
1070
- const result = intersectReachable([
1071
- {
1072
- seedEntityIds: [alice],
1073
- steps: [{ relationTypes: ["works_on"], entityTypes: ["project"] }],
1074
- },
1075
- {
1076
- seedEntityIds: [bob],
1077
- steps: [{ relationTypes: ["works_on"], entityTypes: ["project"] }],
1078
- },
1079
- ]);
1080
-
1081
- expect(result).toEqual([]);
1082
- });
1083
-
1084
- test("single query is equivalent to collectTypedNeighbors", () => {
1085
- const person = upsertEntity({
1086
- name: "IntersectSingle",
1087
- type: "person",
1088
- aliases: [],
1089
- });
1090
- const tool = upsertEntity({
1091
- name: "IntersectTool",
1092
- type: "tool",
1093
- aliases: [],
1094
- });
1095
-
1096
- upsertEntityRelation({
1097
- sourceEntityId: person,
1098
- targetEntityId: tool,
1099
- relation: "uses",
1100
- });
1101
-
1102
- const result = intersectReachable([
1103
- {
1104
- seedEntityIds: [person],
1105
- steps: [{ relationTypes: ["uses"], entityTypes: ["tool"] }],
1106
- },
1107
- ]);
1108
-
1109
- expect(result).toContain(tool);
1110
- });
1111
-
1112
- test("returns empty for empty queries array", () => {
1113
- const result = intersectReachable([]);
1114
- expect(result).toEqual([]);
1115
- });
1116
- });
1117
- });