@indexnetwork/protocol 0.1.0

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 (365) hide show
  1. package/dist/agents/chat.agent.d.ts +218 -0
  2. package/dist/agents/chat.agent.d.ts.map +1 -0
  3. package/dist/agents/chat.agent.js +884 -0
  4. package/dist/agents/chat.agent.js.map +1 -0
  5. package/dist/agents/chat.prompt.d.ts +18 -0
  6. package/dist/agents/chat.prompt.d.ts.map +1 -0
  7. package/dist/agents/chat.prompt.js +372 -0
  8. package/dist/agents/chat.prompt.js.map +1 -0
  9. package/dist/agents/chat.prompt.modules.d.ts +61 -0
  10. package/dist/agents/chat.prompt.modules.d.ts.map +1 -0
  11. package/dist/agents/chat.prompt.modules.js +366 -0
  12. package/dist/agents/chat.prompt.modules.js.map +1 -0
  13. package/dist/agents/chat.title.generator.d.ts +20 -0
  14. package/dist/agents/chat.title.generator.d.ts.map +1 -0
  15. package/dist/agents/chat.title.generator.js +66 -0
  16. package/dist/agents/chat.title.generator.js.map +1 -0
  17. package/dist/agents/home.categorizer.d.ts +28 -0
  18. package/dist/agents/home.categorizer.d.ts.map +1 -0
  19. package/dist/agents/home.categorizer.js +170 -0
  20. package/dist/agents/home.categorizer.js.map +1 -0
  21. package/dist/agents/hyde.generator.d.ts +27 -0
  22. package/dist/agents/hyde.generator.d.ts.map +1 -0
  23. package/dist/agents/hyde.generator.js +75 -0
  24. package/dist/agents/hyde.generator.js.map +1 -0
  25. package/dist/agents/hyde.strategies.d.ts +17 -0
  26. package/dist/agents/hyde.strategies.d.ts.map +1 -0
  27. package/dist/agents/hyde.strategies.js +29 -0
  28. package/dist/agents/hyde.strategies.js.map +1 -0
  29. package/dist/agents/intent.clarifier.d.ts +29 -0
  30. package/dist/agents/intent.clarifier.d.ts.map +1 -0
  31. package/dist/agents/intent.clarifier.js +186 -0
  32. package/dist/agents/intent.clarifier.js.map +1 -0
  33. package/dist/agents/intent.indexer.d.ts +77 -0
  34. package/dist/agents/intent.indexer.d.ts.map +1 -0
  35. package/dist/agents/intent.indexer.js +164 -0
  36. package/dist/agents/intent.indexer.js.map +1 -0
  37. package/dist/agents/intent.inferrer.d.ts +95 -0
  38. package/dist/agents/intent.inferrer.d.ts.map +1 -0
  39. package/dist/agents/intent.inferrer.js +238 -0
  40. package/dist/agents/intent.inferrer.js.map +1 -0
  41. package/dist/agents/intent.reconciler.d.ts +106 -0
  42. package/dist/agents/intent.reconciler.d.ts.map +1 -0
  43. package/dist/agents/intent.reconciler.js +184 -0
  44. package/dist/agents/intent.reconciler.js.map +1 -0
  45. package/dist/agents/intent.verifier.d.ts +97 -0
  46. package/dist/agents/intent.verifier.d.ts.map +1 -0
  47. package/dist/agents/intent.verifier.js +234 -0
  48. package/dist/agents/intent.verifier.js.map +1 -0
  49. package/dist/agents/invite.generator.d.ts +47 -0
  50. package/dist/agents/invite.generator.d.ts.map +1 -0
  51. package/dist/agents/invite.generator.js +56 -0
  52. package/dist/agents/invite.generator.js.map +1 -0
  53. package/dist/agents/lens.inferrer.d.ts +37 -0
  54. package/dist/agents/lens.inferrer.d.ts.map +1 -0
  55. package/dist/agents/lens.inferrer.js +98 -0
  56. package/dist/agents/lens.inferrer.js.map +1 -0
  57. package/dist/agents/model.config.d.ts +120 -0
  58. package/dist/agents/model.config.d.ts.map +1 -0
  59. package/dist/agents/model.config.js +76 -0
  60. package/dist/agents/model.config.js.map +1 -0
  61. package/dist/agents/negotiation.insights.generator.d.ts +32 -0
  62. package/dist/agents/negotiation.insights.generator.d.ts.map +1 -0
  63. package/dist/agents/negotiation.insights.generator.js +105 -0
  64. package/dist/agents/negotiation.insights.generator.js.map +1 -0
  65. package/dist/agents/negotiation.proposer.d.ts +26 -0
  66. package/dist/agents/negotiation.proposer.d.ts.map +1 -0
  67. package/dist/agents/negotiation.proposer.js +67 -0
  68. package/dist/agents/negotiation.proposer.js.map +1 -0
  69. package/dist/agents/negotiation.responder.d.ts +26 -0
  70. package/dist/agents/negotiation.responder.d.ts.map +1 -0
  71. package/dist/agents/negotiation.responder.js +71 -0
  72. package/dist/agents/negotiation.responder.js.map +1 -0
  73. package/dist/agents/opportunity.evaluator.d.ts +253 -0
  74. package/dist/agents/opportunity.evaluator.d.ts.map +1 -0
  75. package/dist/agents/opportunity.evaluator.js +413 -0
  76. package/dist/agents/opportunity.evaluator.js.map +1 -0
  77. package/dist/agents/opportunity.presenter.d.ts +115 -0
  78. package/dist/agents/opportunity.presenter.d.ts.map +1 -0
  79. package/dist/agents/opportunity.presenter.js +524 -0
  80. package/dist/agents/opportunity.presenter.js.map +1 -0
  81. package/dist/agents/profile.generator.d.ts +67 -0
  82. package/dist/agents/profile.generator.d.ts.map +1 -0
  83. package/dist/agents/profile.generator.js +97 -0
  84. package/dist/agents/profile.generator.js.map +1 -0
  85. package/dist/agents/profile.hyde.generator.d.ts +43 -0
  86. package/dist/agents/profile.hyde.generator.d.ts.map +1 -0
  87. package/dist/agents/profile.hyde.generator.js +113 -0
  88. package/dist/agents/profile.hyde.generator.js.map +1 -0
  89. package/dist/agents/suggestion.generator.d.ts +24 -0
  90. package/dist/agents/suggestion.generator.d.ts.map +1 -0
  91. package/dist/agents/suggestion.generator.js +96 -0
  92. package/dist/agents/suggestion.generator.js.map +1 -0
  93. package/dist/graphs/chat.graph.d.ts +312 -0
  94. package/dist/graphs/chat.graph.d.ts.map +1 -0
  95. package/dist/graphs/chat.graph.js +267 -0
  96. package/dist/graphs/chat.graph.js.map +1 -0
  97. package/dist/graphs/home.graph.d.ts +180 -0
  98. package/dist/graphs/home.graph.d.ts.map +1 -0
  99. package/dist/graphs/home.graph.js +598 -0
  100. package/dist/graphs/home.graph.js.map +1 -0
  101. package/dist/graphs/hyde.graph.d.ts +110 -0
  102. package/dist/graphs/hyde.graph.d.ts.map +1 -0
  103. package/dist/graphs/hyde.graph.js +235 -0
  104. package/dist/graphs/hyde.graph.js.map +1 -0
  105. package/dist/graphs/index.graph.d.ts +620 -0
  106. package/dist/graphs/index.graph.d.ts.map +1 -0
  107. package/dist/graphs/index.graph.js +226 -0
  108. package/dist/graphs/index.graph.js.map +1 -0
  109. package/dist/graphs/index_membership.graph.d.ts +250 -0
  110. package/dist/graphs/index_membership.graph.d.ts.map +1 -0
  111. package/dist/graphs/index_membership.graph.js +204 -0
  112. package/dist/graphs/index_membership.graph.js.map +1 -0
  113. package/dist/graphs/intent.graph.d.ts +490 -0
  114. package/dist/graphs/intent.graph.d.ts.map +1 -0
  115. package/dist/graphs/intent.graph.js +787 -0
  116. package/dist/graphs/intent.graph.js.map +1 -0
  117. package/dist/graphs/intent_index.graph.d.ts +396 -0
  118. package/dist/graphs/intent_index.graph.d.ts.map +1 -0
  119. package/dist/graphs/intent_index.graph.js +331 -0
  120. package/dist/graphs/intent_index.graph.js.map +1 -0
  121. package/dist/graphs/maintenance.graph.d.ts +177 -0
  122. package/dist/graphs/maintenance.graph.d.ts.map +1 -0
  123. package/dist/graphs/maintenance.graph.js +173 -0
  124. package/dist/graphs/maintenance.graph.js.map +1 -0
  125. package/dist/graphs/negotiation.graph.d.ts +819 -0
  126. package/dist/graphs/negotiation.graph.d.ts.map +1 -0
  127. package/dist/graphs/negotiation.graph.js +255 -0
  128. package/dist/graphs/negotiation.graph.js.map +1 -0
  129. package/dist/graphs/opportunity.graph.d.ts +1082 -0
  130. package/dist/graphs/opportunity.graph.d.ts.map +1 -0
  131. package/dist/graphs/opportunity.graph.js +2534 -0
  132. package/dist/graphs/opportunity.graph.js.map +1 -0
  133. package/dist/graphs/profile.graph.d.ts +617 -0
  134. package/dist/graphs/profile.graph.d.ts.map +1 -0
  135. package/dist/graphs/profile.graph.js +839 -0
  136. package/dist/graphs/profile.graph.js.map +1 -0
  137. package/dist/graphs/tests/chat.graph.mocks.d.ts +104 -0
  138. package/dist/graphs/tests/chat.graph.mocks.d.ts.map +1 -0
  139. package/dist/graphs/tests/chat.graph.mocks.js +225 -0
  140. package/dist/graphs/tests/chat.graph.mocks.js.map +1 -0
  141. package/dist/index.d.ts +62 -0
  142. package/dist/index.d.ts.map +1 -0
  143. package/dist/index.js +44 -0
  144. package/dist/index.js.map +1 -0
  145. package/dist/interfaces/auth.interface.d.ts +15 -0
  146. package/dist/interfaces/auth.interface.d.ts.map +1 -0
  147. package/dist/interfaces/auth.interface.js +2 -0
  148. package/dist/interfaces/auth.interface.js.map +1 -0
  149. package/dist/interfaces/cache.interface.d.ts +43 -0
  150. package/dist/interfaces/cache.interface.d.ts.map +1 -0
  151. package/dist/interfaces/cache.interface.js +6 -0
  152. package/dist/interfaces/cache.interface.js.map +1 -0
  153. package/dist/interfaces/chat-session.interface.d.ts +11 -0
  154. package/dist/interfaces/chat-session.interface.d.ts.map +1 -0
  155. package/dist/interfaces/chat-session.interface.js +2 -0
  156. package/dist/interfaces/chat-session.interface.js.map +1 -0
  157. package/dist/interfaces/contact.interface.d.ts +48 -0
  158. package/dist/interfaces/contact.interface.d.ts.map +1 -0
  159. package/dist/interfaces/contact.interface.js +2 -0
  160. package/dist/interfaces/contact.interface.js.map +1 -0
  161. package/dist/interfaces/database.interface.d.ts +1495 -0
  162. package/dist/interfaces/database.interface.d.ts.map +1 -0
  163. package/dist/interfaces/database.interface.js +2 -0
  164. package/dist/interfaces/database.interface.js.map +1 -0
  165. package/dist/interfaces/embedder.interface.d.ts +85 -0
  166. package/dist/interfaces/embedder.interface.d.ts.map +1 -0
  167. package/dist/interfaces/embedder.interface.js +5 -0
  168. package/dist/interfaces/embedder.interface.js.map +1 -0
  169. package/dist/interfaces/enrichment.interface.d.ts +40 -0
  170. package/dist/interfaces/enrichment.interface.d.ts.map +1 -0
  171. package/dist/interfaces/enrichment.interface.js +2 -0
  172. package/dist/interfaces/enrichment.interface.js.map +1 -0
  173. package/dist/interfaces/integration.interface.d.ts +91 -0
  174. package/dist/interfaces/integration.interface.d.ts.map +1 -0
  175. package/dist/interfaces/integration.interface.js +2 -0
  176. package/dist/interfaces/integration.interface.js.map +1 -0
  177. package/dist/interfaces/queue.interface.d.ts +17 -0
  178. package/dist/interfaces/queue.interface.d.ts.map +1 -0
  179. package/dist/interfaces/queue.interface.js +5 -0
  180. package/dist/interfaces/queue.interface.js.map +1 -0
  181. package/dist/interfaces/scraper.interface.d.ts +31 -0
  182. package/dist/interfaces/scraper.interface.d.ts.map +1 -0
  183. package/dist/interfaces/scraper.interface.js +2 -0
  184. package/dist/interfaces/scraper.interface.js.map +1 -0
  185. package/dist/interfaces/storage.interface.d.ts +46 -0
  186. package/dist/interfaces/storage.interface.d.ts.map +1 -0
  187. package/dist/interfaces/storage.interface.js +6 -0
  188. package/dist/interfaces/storage.interface.js.map +1 -0
  189. package/dist/mcp/mcp.server.d.ts +29 -0
  190. package/dist/mcp/mcp.server.d.ts.map +1 -0
  191. package/dist/mcp/mcp.server.js +171 -0
  192. package/dist/mcp/mcp.server.js.map +1 -0
  193. package/dist/states/chat.state.d.ts +126 -0
  194. package/dist/states/chat.state.d.ts.map +1 -0
  195. package/dist/states/chat.state.js +112 -0
  196. package/dist/states/chat.state.js.map +1 -0
  197. package/dist/states/home.state.d.ts +100 -0
  198. package/dist/states/home.state.d.ts.map +1 -0
  199. package/dist/states/home.state.js +74 -0
  200. package/dist/states/home.state.js.map +1 -0
  201. package/dist/states/hyde.state.d.ts +54 -0
  202. package/dist/states/hyde.state.d.ts.map +1 -0
  203. package/dist/states/hyde.state.js +66 -0
  204. package/dist/states/hyde.state.js.map +1 -0
  205. package/dist/states/index.state.d.ts +179 -0
  206. package/dist/states/index.state.d.ts.map +1 -0
  207. package/dist/states/index.state.js +56 -0
  208. package/dist/states/index.state.js.map +1 -0
  209. package/dist/states/index_membership.state.d.ts +77 -0
  210. package/dist/states/index_membership.state.d.ts.map +1 -0
  211. package/dist/states/index_membership.state.js +43 -0
  212. package/dist/states/index_membership.state.js.map +1 -0
  213. package/dist/states/intent.state.d.ts +203 -0
  214. package/dist/states/intent.state.d.ts.map +1 -0
  215. package/dist/states/intent.state.js +153 -0
  216. package/dist/states/intent.state.js.map +1 -0
  217. package/dist/states/intent_index.state.d.ts +148 -0
  218. package/dist/states/intent_index.state.d.ts.map +1 -0
  219. package/dist/states/intent_index.state.js +100 -0
  220. package/dist/states/intent_index.state.js.map +1 -0
  221. package/dist/states/maintenance.state.d.ts +36 -0
  222. package/dist/states/maintenance.state.d.ts.map +1 -0
  223. package/dist/states/maintenance.state.js +56 -0
  224. package/dist/states/maintenance.state.js.map +1 -0
  225. package/dist/states/negotiation.state.d.ts +230 -0
  226. package/dist/states/negotiation.state.d.ts.map +1 -0
  227. package/dist/states/negotiation.state.js +82 -0
  228. package/dist/states/negotiation.state.js.map +1 -0
  229. package/dist/states/opportunity.state.d.ts +300 -0
  230. package/dist/states/opportunity.state.d.ts.map +1 -0
  231. package/dist/states/opportunity.state.js +207 -0
  232. package/dist/states/opportunity.state.js.map +1 -0
  233. package/dist/states/profile.state.d.ts +172 -0
  234. package/dist/states/profile.state.d.ts.map +1 -0
  235. package/dist/states/profile.state.js +133 -0
  236. package/dist/states/profile.state.js.map +1 -0
  237. package/dist/streamers/chat.streamer.d.ts +55 -0
  238. package/dist/streamers/chat.streamer.d.ts.map +1 -0
  239. package/dist/streamers/chat.streamer.js +186 -0
  240. package/dist/streamers/chat.streamer.js.map +1 -0
  241. package/dist/streamers/index.d.ts +3 -0
  242. package/dist/streamers/index.d.ts.map +1 -0
  243. package/dist/streamers/index.js +3 -0
  244. package/dist/streamers/index.js.map +1 -0
  245. package/dist/streamers/response.streamer.d.ts +36 -0
  246. package/dist/streamers/response.streamer.d.ts.map +1 -0
  247. package/dist/streamers/response.streamer.js +46 -0
  248. package/dist/streamers/response.streamer.js.map +1 -0
  249. package/dist/support/chat.utils.d.ts +42 -0
  250. package/dist/support/chat.utils.d.ts.map +1 -0
  251. package/dist/support/chat.utils.js +89 -0
  252. package/dist/support/chat.utils.js.map +1 -0
  253. package/dist/support/debug-meta.sanitizer.d.ts +18 -0
  254. package/dist/support/debug-meta.sanitizer.d.ts.map +1 -0
  255. package/dist/support/debug-meta.sanitizer.js +82 -0
  256. package/dist/support/debug-meta.sanitizer.js.map +1 -0
  257. package/dist/support/feed.health.d.ts +32 -0
  258. package/dist/support/feed.health.d.ts.map +1 -0
  259. package/dist/support/feed.health.js +76 -0
  260. package/dist/support/feed.health.js.map +1 -0
  261. package/dist/support/introducer.discovery.d.ts +78 -0
  262. package/dist/support/introducer.discovery.d.ts.map +1 -0
  263. package/dist/support/introducer.discovery.js +101 -0
  264. package/dist/support/introducer.discovery.js.map +1 -0
  265. package/dist/support/log.d.ts +65 -0
  266. package/dist/support/log.d.ts.map +1 -0
  267. package/dist/support/log.js +76 -0
  268. package/dist/support/log.js.map +1 -0
  269. package/dist/support/lucide.icon-catalog.d.ts +22 -0
  270. package/dist/support/lucide.icon-catalog.d.ts.map +1 -0
  271. package/dist/support/lucide.icon-catalog.js +101 -0
  272. package/dist/support/lucide.icon-catalog.js.map +1 -0
  273. package/dist/support/opportunity.card-text.d.ts +39 -0
  274. package/dist/support/opportunity.card-text.d.ts.map +1 -0
  275. package/dist/support/opportunity.card-text.js +333 -0
  276. package/dist/support/opportunity.card-text.js.map +1 -0
  277. package/dist/support/opportunity.constants.d.ts +9 -0
  278. package/dist/support/opportunity.constants.d.ts.map +1 -0
  279. package/dist/support/opportunity.constants.js +11 -0
  280. package/dist/support/opportunity.constants.js.map +1 -0
  281. package/dist/support/opportunity.discover.d.ts +144 -0
  282. package/dist/support/opportunity.discover.d.ts.map +1 -0
  283. package/dist/support/opportunity.discover.js +610 -0
  284. package/dist/support/opportunity.discover.js.map +1 -0
  285. package/dist/support/opportunity.enricher.d.ts +44 -0
  286. package/dist/support/opportunity.enricher.d.ts.map +1 -0
  287. package/dist/support/opportunity.enricher.js +245 -0
  288. package/dist/support/opportunity.enricher.js.map +1 -0
  289. package/dist/support/opportunity.persist.d.ts +39 -0
  290. package/dist/support/opportunity.persist.d.ts.map +1 -0
  291. package/dist/support/opportunity.persist.js +63 -0
  292. package/dist/support/opportunity.persist.js.map +1 -0
  293. package/dist/support/opportunity.presentation.d.ts +21 -0
  294. package/dist/support/opportunity.presentation.d.ts.map +1 -0
  295. package/dist/support/opportunity.presentation.js +75 -0
  296. package/dist/support/opportunity.presentation.js.map +1 -0
  297. package/dist/support/opportunity.sanitize.d.ts +18 -0
  298. package/dist/support/opportunity.sanitize.d.ts.map +1 -0
  299. package/dist/support/opportunity.sanitize.js +89 -0
  300. package/dist/support/opportunity.sanitize.js.map +1 -0
  301. package/dist/support/opportunity.utils.d.ts +99 -0
  302. package/dist/support/opportunity.utils.d.ts.map +1 -0
  303. package/dist/support/opportunity.utils.js +184 -0
  304. package/dist/support/opportunity.utils.js.map +1 -0
  305. package/dist/support/performance.d.ts +19 -0
  306. package/dist/support/performance.d.ts.map +1 -0
  307. package/dist/support/performance.js +43 -0
  308. package/dist/support/performance.js.map +1 -0
  309. package/dist/support/profile.enrichment-display-name.d.ts +16 -0
  310. package/dist/support/profile.enrichment-display-name.d.ts.map +1 -0
  311. package/dist/support/profile.enrichment-display-name.js +22 -0
  312. package/dist/support/profile.enrichment-display-name.js.map +1 -0
  313. package/dist/support/protocol.logger.d.ts +22 -0
  314. package/dist/support/protocol.logger.d.ts.map +1 -0
  315. package/dist/support/protocol.logger.js +44 -0
  316. package/dist/support/protocol.logger.js.map +1 -0
  317. package/dist/support/request-context.d.ts +19 -0
  318. package/dist/support/request-context.d.ts.map +1 -0
  319. package/dist/support/request-context.js +7 -0
  320. package/dist/support/request-context.js.map +1 -0
  321. package/dist/tools/contact.tools.d.ts +7 -0
  322. package/dist/tools/contact.tools.d.ts.map +1 -0
  323. package/dist/tools/contact.tools.js +115 -0
  324. package/dist/tools/contact.tools.js.map +1 -0
  325. package/dist/tools/index.d.ts +17 -0
  326. package/dist/tools/index.d.ts.map +1 -0
  327. package/dist/tools/index.js +140 -0
  328. package/dist/tools/index.js.map +1 -0
  329. package/dist/tools/index.tools.d.ts +3 -0
  330. package/dist/tools/index.tools.d.ts.map +1 -0
  331. package/dist/tools/index.tools.js +423 -0
  332. package/dist/tools/index.tools.js.map +1 -0
  333. package/dist/tools/integration.tools.d.ts +13 -0
  334. package/dist/tools/integration.tools.d.ts.map +1 -0
  335. package/dist/tools/integration.tools.js +77 -0
  336. package/dist/tools/integration.tools.js.map +1 -0
  337. package/dist/tools/intent.tools.d.ts +3 -0
  338. package/dist/tools/intent.tools.d.ts.map +1 -0
  339. package/dist/tools/intent.tools.js +458 -0
  340. package/dist/tools/intent.tools.js.map +1 -0
  341. package/dist/tools/opportunity.tools.d.ts +44 -0
  342. package/dist/tools/opportunity.tools.d.ts.map +1 -0
  343. package/dist/tools/opportunity.tools.js +814 -0
  344. package/dist/tools/opportunity.tools.js.map +1 -0
  345. package/dist/tools/profile.tools.d.ts +3 -0
  346. package/dist/tools/profile.tools.d.ts.map +1 -0
  347. package/dist/tools/profile.tools.js +513 -0
  348. package/dist/tools/profile.tools.js.map +1 -0
  349. package/dist/tools/tool.helpers.d.ts +225 -0
  350. package/dist/tools/tool.helpers.d.ts.map +1 -0
  351. package/dist/tools/tool.helpers.js +172 -0
  352. package/dist/tools/tool.helpers.js.map +1 -0
  353. package/dist/tools/tool.registry.d.ts +12 -0
  354. package/dist/tools/tool.registry.d.ts.map +1 -0
  355. package/dist/tools/tool.registry.js +62 -0
  356. package/dist/tools/tool.registry.js.map +1 -0
  357. package/dist/tools/utility.tools.d.ts +3 -0
  358. package/dist/tools/utility.tools.d.ts.map +1 -0
  359. package/dist/tools/utility.tools.js +107 -0
  360. package/dist/tools/utility.tools.js.map +1 -0
  361. package/dist/types/chat-streaming.types.d.ts +472 -0
  362. package/dist/types/chat-streaming.types.d.ts.map +1 -0
  363. package/dist/types/chat-streaming.types.js +260 -0
  364. package/dist/types/chat-streaming.types.js.map +1 -0
  365. package/package.json +32 -0
@@ -0,0 +1,2534 @@
1
+ /**
2
+ * Opportunity Graph: Linear Multi-Step Workflow for Opportunity Discovery
3
+ *
4
+ * Architecture: Follows intent graph pattern with Annotation-based state.
5
+ * Flow: Prep → Scope → Discovery → Evaluation → Ranking → Persist → END
6
+ *
7
+ * Key Constraints:
8
+ * - Opportunities only between intents sharing the same index
9
+ * - Both intents must have hyde documents for semantic matching
10
+ * - Non-indexed intents cannot participate in discovery
11
+ *
12
+ * Constructor injects Database, Embedder, and compiled HyDE graph.
13
+ */
14
+ import { StateGraph, START, END } from '@langchain/langgraph';
15
+ import { OpportunityGraphState, } from '../states/opportunity.state.js';
16
+ import { OpportunityEvaluator, } from '../agents/opportunity.evaluator.js';
17
+ import { IntentIndexer } from '../agents/intent.indexer.js';
18
+ import { getModelName } from '../agents/model.config.js';
19
+ import { validateOpportunityActors } from '../support/opportunity.utils.js';
20
+ import { persistOpportunities } from '../support/opportunity.persist.js';
21
+ import { negotiateCandidates } from "./negotiation.graph.js";
22
+ import { protocolLogger, withCallLogging } from '../support/protocol.logger.js';
23
+ import { timed } from '../support/performance.js';
24
+ import { requestContext } from "../support/request-context.js";
25
+ const logger = protocolLogger('OpportunityGraph');
26
+ /**
27
+ * Builds a compact text summary of the discoverer's profile and active intents
28
+ * for use as profileContext in HyDE generation.
29
+ * @param profile - The discoverer's profile data (identity, attributes)
30
+ * @param intents - The discoverer's indexed intents (capped at 5)
31
+ * @returns A context string, or undefined if no meaningful data is available
32
+ */
33
+ export function buildDiscovererContext(profile, intents) {
34
+ const lines = [];
35
+ if (profile) {
36
+ const identity = profile.identity;
37
+ const attrs = profile.attributes;
38
+ if (identity?.name || identity?.bio) {
39
+ lines.push(`Profile: ${[identity.name, identity.bio].filter(Boolean).join(', ')}`);
40
+ }
41
+ if (identity?.location) {
42
+ lines.push(`Location: ${identity.location}`);
43
+ }
44
+ if (attrs?.skills?.length) {
45
+ lines.push(`Skills: ${attrs.skills.join(', ')}`);
46
+ }
47
+ if (attrs?.interests?.length) {
48
+ lines.push(`Interests: ${attrs.interests.join(', ')}`);
49
+ }
50
+ }
51
+ if (intents?.length) {
52
+ // indexedIntents preserves DB order from getActiveIntents (newest first),
53
+ // so slice(0, 5) is deterministic without an explicit sort.
54
+ const capped = intents.slice(0, 5);
55
+ lines.push('');
56
+ lines.push('Active intents:');
57
+ for (const intent of capped) {
58
+ lines.push(`- ${intent.payload}`);
59
+ }
60
+ }
61
+ return lines.length > 0 ? lines.join('\n') : undefined;
62
+ }
63
+ /**
64
+ * Factory class to build and compile the Opportunity Graph.
65
+ * Uses dependency injection for testability.
66
+ */
67
+ export class OpportunityGraphFactory {
68
+ constructor(database, embedder, hydeGenerator, optionalEvaluator, queueNotification, negotiationGraph) {
69
+ this.database = database;
70
+ this.embedder = embedder;
71
+ this.hydeGenerator = hydeGenerator;
72
+ this.optionalEvaluator = optionalEvaluator;
73
+ this.queueNotification = queueNotification;
74
+ this.negotiationGraph = negotiationGraph;
75
+ }
76
+ createGraph() {
77
+ const evaluatorAgent = this.optionalEvaluator ?? new OpportunityEvaluator();
78
+ // ═══════════════════════════════════════════════════════════════
79
+ // NODE DEFINITIONS
80
+ // ═══════════════════════════════════════════════════════════════
81
+ /**
82
+ * Wraps a graph node function to emit agent_start/agent_end trace events
83
+ * at its boundaries so the frontend TRACE panel shows real-time progress.
84
+ * @param traceName - Kebab-case agent name (e.g. "opportunity-prep")
85
+ * @param nodeFn - The original node function
86
+ * @param summaryFn - Optional function to derive a summary string from the node result
87
+ */
88
+ function withNodeTrace(traceName, nodeFn, summaryFn) {
89
+ return async (state) => {
90
+ const traceEmitter = requestContext.getStore()?.traceEmitter;
91
+ const nodeStart = Date.now();
92
+ traceEmitter?.({ type: "agent_start", name: traceName });
93
+ try {
94
+ const result = await nodeFn(state);
95
+ const durationMs = Date.now() - nodeStart;
96
+ const summary = summaryFn?.(result) ?? undefined;
97
+ traceEmitter?.({ type: "agent_end", name: traceName, durationMs, summary });
98
+ return result;
99
+ }
100
+ catch (err) {
101
+ const durationMs = Date.now() - nodeStart;
102
+ const errMsg = err instanceof Error ? err.message : String(err);
103
+ traceEmitter?.({ type: "agent_end", name: traceName, durationMs, summary: `error: ${errMsg}` });
104
+ throw err;
105
+ }
106
+ };
107
+ }
108
+ /**
109
+ * Node 0: Prep
110
+ * Fetches user's index memberships and validates requirements.
111
+ * Returns empty if user has no index memberships (requirement).
112
+ */
113
+ const prepNode = withNodeTrace("opportunity-prep", async (state) => timed("OpportunityGraph.prep", async () => withCallLogging(logger, '[Graph:Prep] prepNode', {
114
+ userId: state.userId,
115
+ hasSearchQuery: !!state.searchQuery,
116
+ requestedIndexId: state.indexId ?? undefined,
117
+ }, async () => {
118
+ // Use getIndexMemberships (all memberships) for search scope — NOT getUserIndexIds
119
+ // (which filters by autoAssign=true and is intended only for intent assignment).
120
+ const memberships = await this.database.getIndexMemberships(state.userId);
121
+ const userIndexIds = memberships.map(m => m.indexId);
122
+ if (userIndexIds.length === 0) {
123
+ logger.verbose('[Graph:Prep] User has no index memberships - cannot find opportunities');
124
+ return {
125
+ userIndexes: [],
126
+ sourceProfile: null,
127
+ error: 'You need to join at least one index to find opportunities.',
128
+ };
129
+ }
130
+ const discoveryUserId = state.onBehalfOfUserId ?? state.userId;
131
+ const [intents, profile] = await Promise.all([
132
+ this.database.getActiveIntents(discoveryUserId),
133
+ this.database.getProfile(discoveryUserId),
134
+ ]);
135
+ const indexedIntents = intents.map((intent) => ({
136
+ intentId: intent.id,
137
+ payload: intent.payload,
138
+ summary: intent.summary ?? undefined,
139
+ indexes: [],
140
+ }));
141
+ const sourceProfile = profile
142
+ ? {
143
+ embedding: profile.embedding ?? null,
144
+ identity: profile.identity ?? undefined,
145
+ narrative: profile.narrative ?? undefined,
146
+ attributes: profile.attributes ?? undefined,
147
+ }
148
+ : null;
149
+ return {
150
+ userIndexes: userIndexIds,
151
+ indexedIntents,
152
+ sourceProfile,
153
+ trace: [{
154
+ node: "prep",
155
+ detail: `${userIndexIds.length} index(es), ${intents.length} intent(s), ${profile ? 'profile loaded' : 'no profile'}`,
156
+ }],
157
+ };
158
+ }, { context: { userId: state.userId }, logOutput: true }).catch((error) => {
159
+ const errMsg = error instanceof Error ? error.message : String(error);
160
+ logger.error('[Graph:Prep] Failed', { error });
161
+ return {
162
+ error: 'Failed to prepare opportunity search. Please try again.',
163
+ trace: [{
164
+ node: "prep_fatal",
165
+ detail: `Prep failed: ${errMsg}`,
166
+ data: { error: errMsg },
167
+ }],
168
+ };
169
+ })), (result) => {
170
+ const r = result;
171
+ if (r?.error)
172
+ return `error: ${r.error}`;
173
+ const indexes = r?.userIndexes;
174
+ const intents = r?.indexedIntents;
175
+ return indexes && intents ? `${indexes.length} index(es), ${intents.length} intent(s)` : undefined;
176
+ });
177
+ /**
178
+ * Node 1: Scope
179
+ * Determines which indexes to search within.
180
+ * If indexId provided: searches only that index.
181
+ * Otherwise: searches all user's indexes.
182
+ */
183
+ const scopeNode = withNodeTrace("opportunity-scope", async (state) => {
184
+ return timed("OpportunityGraph.scope", async () => {
185
+ logger.verbose('[Graph:Scope] Determining search scope', {
186
+ requestedIndexId: state.indexId,
187
+ userIndexesCount: state.userIndexes.length,
188
+ });
189
+ try {
190
+ let targetIndexIds;
191
+ if (state.indexId) {
192
+ // Validate user is member of requested index
193
+ if (!state.userIndexes.includes(state.indexId)) {
194
+ logger.warn('[Graph:Scope] User not member of requested index', {
195
+ indexId: state.indexId,
196
+ });
197
+ return {
198
+ targetIndexes: [],
199
+ error: 'You are not a member of that index.',
200
+ };
201
+ }
202
+ targetIndexIds = [state.indexId];
203
+ }
204
+ else {
205
+ // Search all user's indexes
206
+ targetIndexIds = state.userIndexes;
207
+ }
208
+ // Fetch index details
209
+ const targetIndexes = await Promise.all(targetIndexIds.map(async (indexId) => {
210
+ const index = await this.database.getIndex(indexId);
211
+ const memberCount = await this.database.getIndexMemberCount(indexId);
212
+ return {
213
+ indexId,
214
+ title: index?.title ?? 'Unknown',
215
+ memberCount,
216
+ };
217
+ }));
218
+ logger.verbose('[Graph:Scope] Scope determined', {
219
+ targetIndexesCount: targetIndexes.length,
220
+ indexes: targetIndexes.map(i => i.title),
221
+ });
222
+ // ── Populate index relevancy scores for dedup tie-breaking ──
223
+ let indexRelevancyScores = {};
224
+ if (state.triggerIntentId) {
225
+ // Background path: look up persisted scores from intent_indexes
226
+ try {
227
+ const scores = await this.database.getIntentIndexScores(state.triggerIntentId);
228
+ for (const { indexId, relevancyScore } of scores) {
229
+ if (relevancyScore != null) {
230
+ indexRelevancyScores[indexId] = relevancyScore;
231
+ }
232
+ }
233
+ }
234
+ catch (err) {
235
+ logger.warn('[Graph:Scope] Failed to load intent index scores', { triggerIntentId: state.triggerIntentId, error: err });
236
+ }
237
+ }
238
+ else if (state.searchQuery?.trim()) {
239
+ // Chat path: score query against target indexes in parallel
240
+ try {
241
+ const indexer = new IntentIndexer();
242
+ const scopeAgentTimings = [];
243
+ const scorableIndexes = targetIndexes.filter(ti => ti.title !== 'Unknown');
244
+ const scoringPromises = scorableIndexes.map(async (ti) => {
245
+ try {
246
+ const ctx = await this.database.getIndexMemberContext(ti.indexId, state.userId);
247
+ if (!ctx?.indexPrompt?.trim() && !ctx?.memberPrompt?.trim()) {
248
+ return { indexId: ti.indexId, score: 1.0 };
249
+ }
250
+ const _indexerStart = Date.now();
251
+ const traceEmitter = requestContext.getStore()?.traceEmitter;
252
+ traceEmitter?.({ type: "agent_start", name: "intent-indexer" });
253
+ const result = await indexer.invoke(state.searchQuery, ctx?.indexPrompt ?? null, ctx?.memberPrompt ?? null);
254
+ const _indexerDuration = Date.now() - _indexerStart;
255
+ traceEmitter?.({ type: "agent_end", name: "intent-indexer", durationMs: _indexerDuration, summary: `Scored index ${ti.indexId}` });
256
+ scopeAgentTimings.push({ name: 'intent.indexer', durationMs: _indexerDuration });
257
+ if (!result)
258
+ return { indexId: ti.indexId, score: 1.0 };
259
+ const score = ctx?.indexPrompt && ctx?.memberPrompt
260
+ ? result.indexScore * 0.6 + result.memberScore * 0.4
261
+ : ctx?.indexPrompt ? result.indexScore : result.memberScore;
262
+ return { indexId: ti.indexId, score };
263
+ }
264
+ catch {
265
+ return { indexId: ti.indexId, score: 1.0 };
266
+ }
267
+ });
268
+ const results = await Promise.all(scoringPromises);
269
+ for (const { indexId, score } of results) {
270
+ indexRelevancyScores[indexId] = score;
271
+ }
272
+ // Accumulate indexer timings into graph state
273
+ if (scopeAgentTimings.length > 0) {
274
+ return {
275
+ targetIndexes,
276
+ indexRelevancyScores,
277
+ agentTimings: scopeAgentTimings,
278
+ trace: [{
279
+ node: "scope",
280
+ detail: `Searching ${targetIndexes.length} index(es): ${targetIndexes.map(i => `${i.title} (${i.memberCount})`).join(', ')}`,
281
+ data: { totalMembers: targetIndexes.reduce((sum, i) => sum + i.memberCount, 0) },
282
+ }],
283
+ };
284
+ }
285
+ }
286
+ catch (err) {
287
+ logger.warn('[Graph:Scope] Failed to score query against indexes', { error: err });
288
+ }
289
+ }
290
+ const totalMembers = targetIndexes.reduce((sum, i) => sum + i.memberCount, 0);
291
+ return {
292
+ targetIndexes,
293
+ indexRelevancyScores,
294
+ trace: [{
295
+ node: "scope",
296
+ detail: `Searching ${targetIndexes.length} index(es): ${targetIndexes.map(i => `${i.title} (${i.memberCount})`).join(', ')}`,
297
+ data: { totalMembers },
298
+ }],
299
+ };
300
+ }
301
+ catch (error) {
302
+ const errMsg = error instanceof Error ? error.message : String(error);
303
+ logger.error('[Graph:Scope] Failed', { error });
304
+ return {
305
+ targetIndexes: [],
306
+ error: 'Failed to determine search scope.',
307
+ trace: [{
308
+ node: "scope_fatal",
309
+ detail: `Scope failed: ${errMsg}`,
310
+ data: { error: errMsg },
311
+ }],
312
+ };
313
+ }
314
+ });
315
+ }, (result) => {
316
+ const r = result;
317
+ if (r?.error)
318
+ return `error: ${r.error}`;
319
+ const indexes = r?.targetIndexes;
320
+ return indexes ? `${indexes.length} index(es) in scope` : undefined;
321
+ });
322
+ /**
323
+ * Node 2: Resolve
324
+ * Resolves trigger intent from triggerIntentId or searchQuery vs indexedIntents;
325
+ * sets discoverySource, resolvedTriggerIntentId, resolvedIntentInIndex for routing (path A/B/C).
326
+ */
327
+ const resolveNode = withNodeTrace("opportunity-resolve", async (state) => {
328
+ return timed("OpportunityGraph.resolve", async () => {
329
+ logger.verbose('[Graph:Resolve] Resolving intent and index membership', {
330
+ triggerIntentId: state.triggerIntentId,
331
+ hasSearchQuery: !!state.searchQuery,
332
+ indexedIntentsCount: state.indexedIntents.length,
333
+ });
334
+ const targetIndexIds = state.targetIndexes.map((t) => t.indexId);
335
+ try {
336
+ let resolvedIntentId;
337
+ if (state.triggerIntentId) {
338
+ const inIndex = await this.database.getIndexIdsForIntent(state.triggerIntentId);
339
+ const inTarget = inIndex.some((id) => targetIndexIds.includes(id));
340
+ resolvedIntentId = state.triggerIntentId;
341
+ const resolvedIntentInIndex = inTarget;
342
+ const discoverySource = resolvedIntentInIndex ? 'intent' : 'profile';
343
+ return {
344
+ resolvedTriggerIntentId: resolvedIntentId,
345
+ resolvedIntentInIndex,
346
+ discoverySource,
347
+ };
348
+ }
349
+ if (state.searchQuery?.trim() && state.indexedIntents.length > 0) {
350
+ const q = state.searchQuery.trim().toLowerCase();
351
+ const matched = state.indexedIntents.find((i) => i.payload?.toLowerCase().includes(q));
352
+ if (matched) {
353
+ resolvedIntentId = matched.intentId;
354
+ const inIndex = await this.database.getIndexIdsForIntent(matched.intentId);
355
+ const resolvedIntentInIndex = inIndex.some((id) => targetIndexIds.includes(id));
356
+ const discoverySource = resolvedIntentInIndex ? 'intent' : 'profile';
357
+ return {
358
+ resolvedTriggerIntentId: resolvedIntentId,
359
+ resolvedIntentInIndex,
360
+ discoverySource,
361
+ };
362
+ }
363
+ logger.warn('[Graph:Resolve] No intent matched search query; leaving resolvedIntentId unset', {
364
+ searchQuery: state.searchQuery,
365
+ indexedIntentsCount: state.indexedIntents.length,
366
+ });
367
+ }
368
+ return {
369
+ resolvedTriggerIntentId: undefined,
370
+ resolvedIntentInIndex: false,
371
+ discoverySource: 'profile',
372
+ };
373
+ }
374
+ catch (err) {
375
+ const errMsg = err instanceof Error ? err.message : String(err);
376
+ logger.error('[Graph:Resolve] Failed', {
377
+ triggerIntentId: state.triggerIntentId,
378
+ searchQuery: state.searchQuery,
379
+ error: err,
380
+ });
381
+ return {
382
+ resolvedTriggerIntentId: undefined,
383
+ resolvedIntentInIndex: false,
384
+ discoverySource: 'profile',
385
+ error: errMsg || 'Resolve failed',
386
+ trace: [{
387
+ node: "resolve_fatal",
388
+ detail: `Resolve failed: ${errMsg}`,
389
+ data: { error: errMsg },
390
+ }],
391
+ };
392
+ }
393
+ });
394
+ }, (result) => {
395
+ const r = result;
396
+ if (r?.error)
397
+ return `error: ${r.error}`;
398
+ return r?.discoverySource ? `source: ${r.discoverySource}` : undefined;
399
+ });
400
+ /**
401
+ * Node 3: Discovery
402
+ * Generates HyDE embeddings and performs semantic search (path A), or profile-as-source search (path B/C).
403
+ */
404
+ const discoveryNode = withNodeTrace("opportunity-discovery", async (state) => {
405
+ const self = this;
406
+ return timed("OpportunityGraph.discovery", async () => {
407
+ const startTime = Date.now();
408
+ const discoveryUserId = state.onBehalfOfUserId ?? state.userId;
409
+ /** Filter candidates to targetUserId when set (direct-connection mode). */
410
+ const filterByTarget = (candidates) => {
411
+ if (!state.targetUserId)
412
+ return candidates;
413
+ const filtered = candidates.filter(c => c.candidateUserId === state.targetUserId);
414
+ logger.verbose('[Graph:Discovery] targetUserId filter applied', {
415
+ targetUserId: state.targetUserId,
416
+ before: candidates.length,
417
+ after: filtered.length,
418
+ });
419
+ return filtered;
420
+ };
421
+ // Shared variable to capture lens input data from runQueryHydeDiscovery or intent path
422
+ let discoveryLensInput;
423
+ // Shared variable to capture HyDE output (lenses + documents) for trace entries
424
+ let discoveryHydeOutput;
425
+ logger.verbose('[Graph:Discovery] Starting semantic search', {
426
+ targetIndexesCount: state.targetIndexes.length,
427
+ discoverySource: state.discoverySource,
428
+ searchQueryPreview: state.searchQuery?.trim().slice(0, 60) ?? '(none)',
429
+ });
430
+ try {
431
+ if (state.targetIndexes.length === 0) {
432
+ logger.warn('[Graph:Discovery] No target indexes for search');
433
+ return { candidates: [] };
434
+ }
435
+ // ── Direct-connection fast path ──
436
+ // When targetUserId is set (user @-mentioned someone), bypass vector search
437
+ // and construct candidates directly from shared indexes.
438
+ if (state.targetUserId) {
439
+ if (state.targetUserId === discoveryUserId) {
440
+ logger.warn('[Graph:Discovery] Direct-connection target matches discoverer; skipping self-match', {
441
+ targetUserId: state.targetUserId,
442
+ });
443
+ return {
444
+ candidates: [],
445
+ trace: [{
446
+ node: "discovery",
447
+ detail: "Direct connection skipped: target user is discoverer",
448
+ data: { targetUserId: state.targetUserId },
449
+ }],
450
+ };
451
+ }
452
+ logger.verbose('[Graph:Discovery] Direct-connection mode — bypassing vector search', {
453
+ targetUserId: state.targetUserId,
454
+ });
455
+ const targetMemberships = await this.database.getIndexMemberships(state.targetUserId);
456
+ const targetUserIndexIds = targetMemberships.map(m => m.indexId);
457
+ const sharedIndexIds = state.targetIndexes
458
+ .filter(ti => targetUserIndexIds.includes(ti.indexId))
459
+ .map(ti => ti.indexId);
460
+ if (sharedIndexIds.length === 0) {
461
+ logger.warn('[Graph:Discovery] Target user shares no indexes with discoverer', {
462
+ targetUserId: state.targetUserId,
463
+ discovererIndexes: state.targetIndexes.map(ti => ti.indexId),
464
+ });
465
+ return {
466
+ candidates: [],
467
+ trace: [{
468
+ node: "discovery",
469
+ detail: `Direct connection: target user shares no indexes`,
470
+ data: { targetUserId: state.targetUserId },
471
+ }],
472
+ };
473
+ }
474
+ // Fetch target user's active intents to build intent-level candidates
475
+ const targetIntents = await this.database.getActiveIntents(state.targetUserId);
476
+ const directCandidates = [];
477
+ if (targetIntents.length > 0) {
478
+ // Build one candidate per intent per shared index it belongs to
479
+ for (const intent of targetIntents) {
480
+ const intentIndexIds = await this.database.getIndexIdsForIntent(intent.id);
481
+ const overlapping = sharedIndexIds.filter(id => intentIndexIds.includes(id));
482
+ for (const indexId of overlapping) {
483
+ directCandidates.push({
484
+ candidateUserId: state.targetUserId,
485
+ candidateIntentId: intent.id,
486
+ indexId,
487
+ similarity: 1.0,
488
+ lens: 'explicit_mention',
489
+ candidatePayload: intent.payload,
490
+ candidateSummary: intent.summary ?? undefined,
491
+ discoverySource: 'query',
492
+ });
493
+ }
494
+ }
495
+ }
496
+ // Always add a profile-level candidate (so evaluation runs even without intents)
497
+ if (directCandidates.length === 0) {
498
+ directCandidates.push({
499
+ candidateUserId: state.targetUserId,
500
+ candidateIntentId: undefined,
501
+ indexId: sharedIndexIds[0],
502
+ similarity: 1.0,
503
+ lens: 'explicit_mention',
504
+ candidatePayload: '',
505
+ candidateSummary: undefined,
506
+ discoverySource: 'query',
507
+ });
508
+ }
509
+ logger.verbose('[Graph:Discovery] Direct candidates constructed', {
510
+ count: directCandidates.length,
511
+ sharedIndexes: sharedIndexIds.length,
512
+ targetIntents: targetIntents.length,
513
+ });
514
+ return {
515
+ candidates: directCandidates,
516
+ trace: [{
517
+ node: "discovery",
518
+ detail: `Direct connection → ${directCandidates.length} candidate(s) from ${sharedIndexIds.length} shared index(es)`,
519
+ data: {
520
+ targetUserId: state.targetUserId,
521
+ candidateCount: directCandidates.length,
522
+ sharedIndexes: sharedIndexIds.length,
523
+ durationMs: Date.now() - startTime,
524
+ },
525
+ }],
526
+ };
527
+ }
528
+ // Search limits - fixed values for candidate retrieval
529
+ // (The options.limit controls final output, not search pool)
530
+ const limitPerStrategy = 30;
531
+ const perIndexLimit = 80;
532
+ // Similarity threshold for recall (0.30 = 30% similarity)
533
+ const minScore = 0.3;
534
+ if (state.discoverySource === 'profile') {
535
+ const embedding = state.sourceProfile?.embedding ?? null;
536
+ const vector = Array.isArray(embedding) && embedding.length > 0 && typeof embedding[0] === 'number'
537
+ ? embedding
538
+ : Array.isArray(embedding) && Array.isArray(embedding[0])
539
+ ? embedding[0]
540
+ : null;
541
+ // ALWAYS run query-based HyDE when we have a search query (e.g., "looking for investors")
542
+ // This ensures we use the right strategies (investor, mentor, etc.) not just mirror
543
+ if (state.searchQuery?.trim()) {
544
+ logger.verbose('[Graph:Discovery] Profile source with searchQuery → running query HyDE path for broader search', {
545
+ searchQuery: state.searchQuery.trim().substring(0, 80),
546
+ hasProfileVector: !!vector,
547
+ });
548
+ const queryCandidates = await runQueryHydeDiscovery();
549
+ logger.verbose('[Graph:Discovery] Query HyDE path complete', { candidatesFound: queryCandidates.length });
550
+ // Build trace entries for this path
551
+ const traceEntries = [];
552
+ // Lens input trace (captured from runQueryHydeDiscovery)
553
+ if (discoveryLensInput) {
554
+ traceEntries.push({
555
+ node: "lens_input",
556
+ detail: "Profile context for lens inference",
557
+ data: discoveryLensInput,
558
+ });
559
+ }
560
+ // Lens output and HyDE document traces (captured from runQueryHydeDiscovery)
561
+ if (discoveryHydeOutput) {
562
+ if (discoveryHydeOutput.lenses.length > 0) {
563
+ traceEntries.push({
564
+ node: "lens_output",
565
+ detail: `Inferred ${discoveryHydeOutput.lenses.length} lens(es): ${discoveryHydeOutput.lenses.map(l => l.label).join(', ')}`,
566
+ data: { lenses: discoveryHydeOutput.lenses, model: getModelName("lensInferrer") },
567
+ });
568
+ }
569
+ for (const [lens, doc] of Object.entries(discoveryHydeOutput.hydeDocuments)) {
570
+ if (doc?.hydeText) {
571
+ traceEntries.push({
572
+ node: "hyde_query",
573
+ detail: `[${lens}] "${doc.hydeText.slice(0, 120)}${doc.hydeText.length > 120 ? '...' : ''}"`,
574
+ data: { lens, hydeTextPreview: doc.hydeText.slice(0, 300) + (doc.hydeText.length > 300 ? '...' : '') },
575
+ });
576
+ }
577
+ }
578
+ }
579
+ // Compute per-lens stats from deduped candidates
580
+ const lensStats = {};
581
+ for (const c of queryCandidates) {
582
+ const s = c.lens || 'unknown';
583
+ if (!lensStats[s])
584
+ lensStats[s] = { count: 0, avgSimilarity: 0 };
585
+ lensStats[s].count++;
586
+ lensStats[s].avgSimilarity += c.similarity;
587
+ }
588
+ for (const s of Object.values(lensStats)) {
589
+ s.avgSimilarity = s.count > 0 ? Math.round((s.avgSimilarity / s.count) * 1000) / 1000 : 0;
590
+ }
591
+ traceEntries.push({
592
+ node: "discovery",
593
+ detail: `HyDE search → ${queryCandidates.length} candidate(s) from query path`,
594
+ data: {
595
+ candidateCount: queryCandidates.length,
596
+ byLens: lensStats,
597
+ searchQuery: state.searchQuery?.trim().slice(0, 80),
598
+ durationMs: Date.now() - startTime,
599
+ model: getModelName("hydeGenerator"),
600
+ },
601
+ });
602
+ // If we also have a profile vector, merge with profile-based results
603
+ if (vector && vector.length > 0) {
604
+ const profileCandidates = [];
605
+ for (const targetIndex of state.targetIndexes) {
606
+ const results = await this.embedder.searchWithProfileEmbedding(vector, {
607
+ indexScope: [targetIndex.indexId],
608
+ excludeUserId: discoveryUserId,
609
+ limitPerStrategy: Math.floor(limitPerStrategy / 2),
610
+ limit: Math.floor(perIndexLimit / 2),
611
+ minScore,
612
+ });
613
+ for (const result of results) {
614
+ profileCandidates.push({
615
+ candidateUserId: result.userId,
616
+ candidateIntentId: result.type === 'intent' ? result.id : undefined,
617
+ indexId: targetIndex.indexId,
618
+ similarity: result.score,
619
+ lens: result.matchedVia,
620
+ candidatePayload: '',
621
+ candidateSummary: undefined,
622
+ discoverySource: 'profile-similarity',
623
+ });
624
+ }
625
+ }
626
+ // Merge and dedupe - keep both intent and profile candidates per user
627
+ const byKey = new Map();
628
+ for (const c of [...queryCandidates, ...profileCandidates]) {
629
+ const key = `${c.candidateUserId}:${c.indexId}:${c.candidateIntentId ?? 'profile'}:${c.discoverySource ?? 'unknown'}`;
630
+ if (!byKey.has(key) || c.similarity > (byKey.get(key)?.similarity ?? 0)) {
631
+ byKey.set(key, c);
632
+ }
633
+ }
634
+ const merged = Array.from(byKey.values());
635
+ logger.verbose('[Graph:Discovery] Merged HyDE + profile candidates', {
636
+ hydeCandidates: queryCandidates.length,
637
+ profileCandidates: profileCandidates.length,
638
+ merged: merged.length
639
+ });
640
+ traceEntries.push({
641
+ node: "discovery",
642
+ detail: `+ Profile search → ${profileCandidates.length} additional, merged to ${merged.length}`,
643
+ data: {
644
+ profileCandidates: profileCandidates.length,
645
+ merged: merged.length,
646
+ },
647
+ });
648
+ return { candidates: filterByTarget(merged), trace: traceEntries };
649
+ }
650
+ return { candidates: filterByTarget(queryCandidates), trace: traceEntries };
651
+ }
652
+ // No search query - use profile embedding directly (mirror-only)
653
+ if (!vector || vector.length === 0) {
654
+ return { candidates: [] };
655
+ }
656
+ const allCandidates = [];
657
+ for (const targetIndex of state.targetIndexes) {
658
+ const results = await this.embedder.searchWithProfileEmbedding(vector, {
659
+ indexScope: [targetIndex.indexId],
660
+ excludeUserId: discoveryUserId,
661
+ limitPerStrategy,
662
+ limit: perIndexLimit,
663
+ minScore,
664
+ });
665
+ for (const result of results) {
666
+ if (result.type === 'intent') {
667
+ allCandidates.push({
668
+ candidateUserId: result.userId,
669
+ candidateIntentId: result.id,
670
+ indexId: targetIndex.indexId,
671
+ similarity: result.score,
672
+ lens: result.matchedVia,
673
+ candidatePayload: '',
674
+ candidateSummary: undefined,
675
+ discoverySource: 'profile-similarity',
676
+ });
677
+ }
678
+ else {
679
+ allCandidates.push({
680
+ candidateUserId: result.userId,
681
+ indexId: targetIndex.indexId,
682
+ similarity: result.score,
683
+ lens: result.matchedVia,
684
+ candidatePayload: '',
685
+ candidateSummary: undefined,
686
+ discoverySource: 'profile-similarity',
687
+ });
688
+ }
689
+ }
690
+ }
691
+ const byUserAndIndex = new Map();
692
+ for (const c of allCandidates) {
693
+ const key = `${c.candidateUserId}:${c.indexId}:${c.candidateIntentId ?? 'profile'}`;
694
+ if (!byUserAndIndex.has(key) || c.similarity > (byUserAndIndex.get(key)?.similarity ?? 0)) {
695
+ byUserAndIndex.set(key, c);
696
+ }
697
+ }
698
+ const candidates = Array.from(byUserAndIndex.values());
699
+ logger.verbose('[Graph:Discovery] Profile-as-source discovery complete', { candidatesFound: candidates.length });
700
+ // Build trace with individual candidate similarity scores
701
+ const traceEntries = [];
702
+ // Show what the profile search is based on
703
+ const profileBio = state.sourceProfile?.identity?.bio;
704
+ const profileContext = state.sourceProfile?.narrative?.context;
705
+ const profileSummary = profileBio || profileContext || '(profile embedding)';
706
+ // Compute per-lens stats from deduped candidates
707
+ const lensStats = {};
708
+ for (const c of candidates) {
709
+ const s = c.lens || 'unknown';
710
+ if (!lensStats[s])
711
+ lensStats[s] = { count: 0, avgSimilarity: 0 };
712
+ lensStats[s].count++;
713
+ lensStats[s].avgSimilarity += c.similarity;
714
+ }
715
+ for (const s of Object.values(lensStats)) {
716
+ s.avgSimilarity = s.count > 0 ? Math.round((s.avgSimilarity / s.count) * 1000) / 1000 : 0;
717
+ }
718
+ traceEntries.push({
719
+ node: "discovery",
720
+ detail: `Profile-based search → ${candidates.length} candidate(s)`,
721
+ data: {
722
+ source: "profile",
723
+ candidateCount: candidates.length,
724
+ byLens: lensStats,
725
+ durationMs: Date.now() - startTime,
726
+ },
727
+ });
728
+ traceEntries.push({
729
+ node: "search_query",
730
+ detail: `Searching for matches to: "${profileSummary.slice(0, 150)}${profileSummary.length > 150 ? '...' : ''}"`,
731
+ data: {
732
+ type: "profile_embedding",
733
+ bio: profileBio,
734
+ context: profileContext,
735
+ },
736
+ });
737
+ // Add top candidates with similarity scores
738
+ const sortedCandidates = [...candidates].sort((a, b) => b.similarity - a.similarity).slice(0, 10);
739
+ for (const c of sortedCandidates) {
740
+ traceEntries.push({
741
+ node: "match",
742
+ detail: `Similarity ${Math.round(c.similarity * 100)}% via ${c.lens}`,
743
+ data: {
744
+ userId: c.candidateUserId,
745
+ similarity: Math.round(c.similarity * 100),
746
+ lens: c.lens,
747
+ hasIntent: !!c.candidateIntentId,
748
+ },
749
+ });
750
+ }
751
+ return {
752
+ candidates: filterByTarget(candidates),
753
+ trace: traceEntries,
754
+ };
755
+ }
756
+ async function runQueryHydeDiscovery() {
757
+ const searchText = state.searchQuery?.trim() ?? '';
758
+ if (!searchText)
759
+ return [];
760
+ logger.verbose('[Graph:Discovery] runQueryHydeDiscovery start', { searchText: searchText.slice(0, 80) });
761
+ const discovererContext = buildDiscovererContext(state.sourceProfile, state.indexedIntents);
762
+ discoveryLensInput = {
763
+ profileContext: discovererContext,
764
+ model: getModelName("lensInferrer"),
765
+ };
766
+ const hydeResult = await self.hydeGenerator.invoke({
767
+ sourceType: 'query',
768
+ sourceText: searchText,
769
+ forceRegenerate: false,
770
+ profileContext: discovererContext,
771
+ });
772
+ const hydeEmbeddings = hydeResult.hydeEmbeddings;
773
+ const lenses = hydeResult.lenses ?? [];
774
+ discoveryHydeOutput = {
775
+ lenses: lenses,
776
+ hydeDocuments: (hydeResult.hydeDocuments ?? {}),
777
+ };
778
+ const embeddingKeys = hydeEmbeddings ? Object.keys(hydeEmbeddings) : [];
779
+ logger.verbose('[Graph:Discovery] HyDE generator result', {
780
+ lensCount: embeddingKeys.length,
781
+ lenses: embeddingKeys,
782
+ });
783
+ if (!hydeEmbeddings || Object.keys(hydeEmbeddings).length === 0)
784
+ return [];
785
+ const lensMap = new Map(lenses.map(l => [l.label, l]));
786
+ const lensEmbeddings = [];
787
+ for (const [label, emb] of Object.entries(hydeEmbeddings)) {
788
+ if (emb?.length) {
789
+ const lens = lensMap.get(label);
790
+ lensEmbeddings.push({ lens: label, corpus: lens?.corpus ?? 'profiles', embedding: emb });
791
+ }
792
+ }
793
+ const all = [];
794
+ await Promise.all(state.targetIndexes.map(async (targetIndex) => {
795
+ const results = await self.embedder.searchWithHydeEmbeddings(lensEmbeddings, {
796
+ indexScope: [targetIndex.indexId],
797
+ excludeUserId: discoveryUserId,
798
+ limitPerStrategy,
799
+ limit: perIndexLimit,
800
+ minScore,
801
+ });
802
+ for (const r of results.filter((x) => x.type === 'intent')) {
803
+ all.push({
804
+ candidateUserId: r.userId,
805
+ candidateIntentId: r.id,
806
+ indexId: targetIndex.indexId,
807
+ similarity: r.score,
808
+ lens: r.matchedVia,
809
+ candidatePayload: '',
810
+ candidateSummary: undefined,
811
+ discoverySource: 'query',
812
+ });
813
+ }
814
+ for (const r of results.filter((x) => x.type === 'profile')) {
815
+ all.push({
816
+ candidateUserId: r.userId,
817
+ indexId: targetIndex.indexId,
818
+ similarity: r.score,
819
+ lens: r.matchedVia,
820
+ candidatePayload: '',
821
+ candidateSummary: undefined,
822
+ discoverySource: 'query',
823
+ });
824
+ }
825
+ }));
826
+ const profileCount = all.filter((c) => !c.candidateIntentId).length;
827
+ const intentCount = all.filter((c) => c.candidateIntentId).length;
828
+ logger.verbose('[Graph:Discovery] searchWithHydeEmbeddings raw results', {
829
+ total: all.length,
830
+ fromProfile: profileCount,
831
+ fromIntent: intentCount,
832
+ });
833
+ const byKey = new Map();
834
+ for (const c of all) {
835
+ // Dedup by candidateUserId + intent (or profile), NOT by indexId.
836
+ // Including indexId caused the same user to appear once per index they belong to.
837
+ const key = `${c.candidateUserId}:${c.candidateIntentId ?? 'profile'}`;
838
+ if (!byKey.has(key) || c.similarity > (byKey.get(key)?.similarity ?? 0)) {
839
+ byKey.set(key, c);
840
+ }
841
+ }
842
+ return Array.from(byKey.values());
843
+ }
844
+ const resolvedIntent = state.resolvedTriggerIntentId
845
+ ? state.indexedIntents.find((i) => i.intentId === state.resolvedTriggerIntentId)
846
+ : state.indexedIntents[0];
847
+ const searchText = state.searchQuery ?? resolvedIntent?.payload ?? '';
848
+ if (!searchText) {
849
+ logger.warn('[Graph:Discovery] No search text available for intent path');
850
+ return { candidates: [] };
851
+ }
852
+ const discovererContext = buildDiscovererContext(state.sourceProfile, state.indexedIntents);
853
+ discoveryLensInput = {
854
+ profileContext: discovererContext,
855
+ model: getModelName("lensInferrer"),
856
+ };
857
+ const hydeResult = await this.hydeGenerator.invoke({
858
+ sourceType: 'query',
859
+ sourceText: searchText,
860
+ forceRegenerate: false,
861
+ profileContext: discovererContext,
862
+ });
863
+ const hydeEmbeddings = hydeResult.hydeEmbeddings;
864
+ const lenses = hydeResult.lenses ?? [];
865
+ if (!hydeEmbeddings || Object.keys(hydeEmbeddings).length === 0) {
866
+ return { hydeEmbeddings: {}, candidates: [] };
867
+ }
868
+ const lensMap = new Map(lenses.map(l => [l.label, l]));
869
+ const lensEmbeddings = [];
870
+ for (const [label, emb] of Object.entries(hydeEmbeddings)) {
871
+ if (emb?.length) {
872
+ const lens = lensMap.get(label);
873
+ lensEmbeddings.push({ lens: label, corpus: lens?.corpus ?? 'profiles', embedding: emb });
874
+ }
875
+ }
876
+ const allCandidates = [];
877
+ await Promise.all(state.targetIndexes.map(async (targetIndex) => {
878
+ const results = await this.embedder.searchWithHydeEmbeddings(lensEmbeddings, {
879
+ indexScope: [targetIndex.indexId],
880
+ excludeUserId: discoveryUserId,
881
+ limitPerStrategy,
882
+ limit: perIndexLimit,
883
+ minScore,
884
+ });
885
+ for (const result of results.filter((r) => r.type === 'intent')) {
886
+ allCandidates.push({
887
+ candidateUserId: result.userId,
888
+ candidateIntentId: result.id,
889
+ indexId: targetIndex.indexId,
890
+ similarity: result.score,
891
+ lens: result.matchedVia,
892
+ candidatePayload: '',
893
+ candidateSummary: undefined,
894
+ discoverySource: 'query',
895
+ });
896
+ }
897
+ for (const result of results.filter((r) => r.type === 'profile')) {
898
+ allCandidates.push({
899
+ candidateUserId: result.userId,
900
+ indexId: targetIndex.indexId,
901
+ similarity: result.score,
902
+ lens: result.matchedVia,
903
+ candidatePayload: '',
904
+ candidateSummary: undefined,
905
+ discoverySource: 'query',
906
+ });
907
+ }
908
+ }));
909
+ const byUserAndIndex = new Map();
910
+ for (const c of allCandidates) {
911
+ const key = `${c.candidateUserId}:${c.indexId}:${c.candidateIntentId ?? 'profile'}`;
912
+ if (!byUserAndIndex.has(key) || c.similarity > (byUserAndIndex.get(key)?.similarity ?? 0)) {
913
+ byUserAndIndex.set(key, c);
914
+ }
915
+ }
916
+ const candidates = Array.from(byUserAndIndex.values());
917
+ logger.verbose('[Graph:Discovery] Intent-path discovery complete', { candidatesFound: candidates.length });
918
+ const usedLenses = Object.keys(hydeEmbeddings);
919
+ // Build trace with individual candidate similarity scores
920
+ const traceEntries = [];
921
+ // Lens input trace
922
+ if (discoveryLensInput) {
923
+ traceEntries.push({
924
+ node: "lens_input",
925
+ detail: "Profile context for lens inference",
926
+ data: discoveryLensInput,
927
+ });
928
+ }
929
+ // Lens output trace
930
+ if (lenses.length > 0) {
931
+ traceEntries.push({
932
+ node: "lens_output",
933
+ detail: `Inferred ${lenses.length} lens(es): ${lenses.map(l => l.label).join(', ')}`,
934
+ data: { lenses, model: getModelName("lensInferrer") },
935
+ });
936
+ }
937
+ // Compute per-lens stats from deduped candidates
938
+ const lensStats = {};
939
+ for (const c of candidates) {
940
+ const s = c.lens || 'unknown';
941
+ if (!lensStats[s])
942
+ lensStats[s] = { count: 0, avgSimilarity: 0 };
943
+ lensStats[s].count++;
944
+ lensStats[s].avgSimilarity += c.similarity;
945
+ }
946
+ for (const s of Object.values(lensStats)) {
947
+ s.avgSimilarity = s.count > 0 ? Math.round((s.avgSimilarity / s.count) * 1000) / 1000 : 0;
948
+ }
949
+ traceEntries.push({
950
+ node: "discovery",
951
+ detail: `Query: "${searchText.slice(0, 50)}${searchText.length > 50 ? '...' : ''}" → ${candidates.length} candidate(s)`,
952
+ data: {
953
+ query: searchText.slice(0, 100),
954
+ lenses: usedLenses,
955
+ candidateCount: candidates.length,
956
+ byLens: lensStats,
957
+ durationMs: Date.now() - startTime,
958
+ model: getModelName("hydeGenerator"),
959
+ },
960
+ });
961
+ // Show the HyDE-generated hypothetical documents used for search
962
+ const hydeDocuments = hydeResult.hydeDocuments;
963
+ if (hydeDocuments) {
964
+ for (const [lens, doc] of Object.entries(hydeDocuments)) {
965
+ if (doc?.hydeText) {
966
+ traceEntries.push({
967
+ node: "hyde_query",
968
+ detail: `[${lens}] "${doc.hydeText.slice(0, 120)}${doc.hydeText.length > 120 ? '...' : ''}"`,
969
+ data: {
970
+ lens,
971
+ hydeTextPreview: doc.hydeText.slice(0, 160) + (doc.hydeText.length > 160 ? '...' : ''),
972
+ },
973
+ });
974
+ }
975
+ }
976
+ }
977
+ // Add top candidates with similarity scores
978
+ const sortedCandidates = [...candidates].sort((a, b) => b.similarity - a.similarity).slice(0, 10);
979
+ for (const c of sortedCandidates) {
980
+ traceEntries.push({
981
+ node: "match",
982
+ detail: `Similarity ${Math.round(c.similarity * 100)}% via ${c.lens}`,
983
+ data: {
984
+ userId: c.candidateUserId,
985
+ similarity: Math.round(c.similarity * 100),
986
+ lens: c.lens,
987
+ hasIntent: !!c.candidateIntentId,
988
+ },
989
+ });
990
+ }
991
+ return {
992
+ hydeEmbeddings: hydeEmbeddings,
993
+ candidates: filterByTarget(candidates),
994
+ trace: traceEntries,
995
+ };
996
+ }
997
+ catch (error) {
998
+ const errMsg = error instanceof Error ? error.message : String(error);
999
+ logger.error('[Graph:Discovery] Failed', { error });
1000
+ return {
1001
+ candidates: [],
1002
+ error: 'Failed to search for candidates.',
1003
+ trace: [{
1004
+ node: "discovery_fatal",
1005
+ detail: `Discovery failed: ${errMsg}`,
1006
+ data: { error: errMsg },
1007
+ }],
1008
+ };
1009
+ }
1010
+ });
1011
+ }, (result) => {
1012
+ const r = result;
1013
+ if (r?.error)
1014
+ return `error: ${r.error}`;
1015
+ const candidates = r?.candidates;
1016
+ return candidates ? `Found ${candidates.length} candidate(s)` : undefined;
1017
+ });
1018
+ /**
1019
+ * Node 3: Evaluation (Entity bundle)
1020
+ * Builds entity bundle from source + candidates, invokes entity-bundle evaluator, maps to EvaluatedOpportunity with indexId from entities.
1021
+ */
1022
+ const evaluationNode = async (state) => {
1023
+ return timed("OpportunityGraph.evaluation", async () => {
1024
+ const startTime = Date.now();
1025
+ logger.verbose('[Graph:Evaluation] Starting evaluation', {
1026
+ candidatesCount: state.candidates.length,
1027
+ });
1028
+ if (state.candidates.length === 0) {
1029
+ logger.verbose('[Graph:Evaluation] No candidates to evaluate');
1030
+ return { evaluatedOpportunities: [], agentTimings: [] };
1031
+ }
1032
+ // Batch candidates to avoid timeout - evaluate top 25 per batch, store remaining
1033
+ const EVAL_BATCH_SIZE = 25;
1034
+ const sortedCandidates = [...state.candidates]
1035
+ .sort((a, b) => b.similarity - a.similarity);
1036
+ // Dedup by userId — when same similarity, prefer index with highest relevancyScore
1037
+ const bestByUser = new Map();
1038
+ for (const c of sortedCandidates) {
1039
+ const existing = bestByUser.get(c.candidateUserId);
1040
+ if (!existing) {
1041
+ bestByUser.set(c.candidateUserId, c);
1042
+ }
1043
+ else if (c.similarity > existing.similarity) {
1044
+ bestByUser.set(c.candidateUserId, c);
1045
+ }
1046
+ else if (c.similarity === existing.similarity) {
1047
+ // Tie-break: prefer index with higher relevancy score
1048
+ const cScore = state.indexRelevancyScores[c.indexId] ?? 0;
1049
+ const existingScore = state.indexRelevancyScores[existing.indexId] ?? 0;
1050
+ if (cScore > existingScore) {
1051
+ bestByUser.set(c.candidateUserId, c);
1052
+ }
1053
+ }
1054
+ }
1055
+ const dedupedCandidates = Array.from(bestByUser.values());
1056
+ // Re-sort by similarity descending (Map iteration order doesn't guarantee sort)
1057
+ dedupedCandidates.sort((a, b) => b.similarity - a.similarity);
1058
+ if (dedupedCandidates.length < sortedCandidates.length) {
1059
+ logger.info("[Graph:Evaluation] Deduped candidates by userId", {
1060
+ before: sortedCandidates.length,
1061
+ after: dedupedCandidates.length,
1062
+ removed: sortedCandidates.length - dedupedCandidates.length,
1063
+ });
1064
+ }
1065
+ const batchToEvaluate = dedupedCandidates.slice(0, EVAL_BATCH_SIZE);
1066
+ const remaining = dedupedCandidates.slice(EVAL_BATCH_SIZE);
1067
+ // Early termination: if search was query-driven and no query-sourced candidates remain,
1068
+ // clear remaining to prevent pointless pagination through profile-similarity leftovers
1069
+ const isQueryDriven = !!state.searchQuery?.trim();
1070
+ const queryRemaining = remaining.filter((c) => c.discoverySource === 'query' || c.discoverySource == null);
1071
+ const effectiveRemaining = isQueryDriven && queryRemaining.length === 0 ? [] : remaining;
1072
+ if (isQueryDriven && remaining.length > 0 && queryRemaining.length === 0) {
1073
+ logger.info("[Graph:Evaluation] Early termination: no query-sourced candidates remain", {
1074
+ droppedProfileCandidates: remaining.length,
1075
+ });
1076
+ }
1077
+ if (effectiveRemaining.length > 0) {
1078
+ logger.verbose('[Graph:Evaluation] Batched candidates for evaluation', {
1079
+ evaluating: batchToEvaluate.length,
1080
+ remaining: effectiveRemaining.length,
1081
+ total: sortedCandidates.length,
1082
+ });
1083
+ }
1084
+ const agentTimingsAccum = [];
1085
+ try {
1086
+ const discoveryUserId = state.onBehalfOfUserId ?? state.userId;
1087
+ const sourceProfile = await this.database.getProfile(discoveryUserId);
1088
+ const sourceEntity = {
1089
+ userId: discoveryUserId,
1090
+ profile: {
1091
+ name: sourceProfile?.identity?.name,
1092
+ bio: sourceProfile?.identity?.bio,
1093
+ location: sourceProfile?.identity?.location,
1094
+ interests: sourceProfile?.attributes?.interests,
1095
+ skills: sourceProfile?.attributes?.skills,
1096
+ context: sourceProfile?.narrative?.context,
1097
+ },
1098
+ intents: state.indexedIntents.slice(0, 5).map((i) => ({
1099
+ intentId: i.intentId,
1100
+ payload: i.payload,
1101
+ summary: i.summary,
1102
+ })),
1103
+ indexId: '', // Placeholder — overwritten per-pairing below
1104
+ ragScore: undefined,
1105
+ matchedVia: undefined,
1106
+ };
1107
+ const candidateEntities = await Promise.all(batchToEvaluate.map(async (c) => {
1108
+ const profile = await this.database.getProfile(c.candidateUserId);
1109
+ let intentPayload = c.candidatePayload;
1110
+ let intentSummary = c.candidateSummary;
1111
+ if (c.candidateIntentId != null && (!intentPayload || intentPayload === '')) {
1112
+ const intent = await this.database.getIntent(c.candidateIntentId);
1113
+ if (intent) {
1114
+ intentPayload = intent.payload;
1115
+ intentSummary = intent.summary ?? undefined;
1116
+ }
1117
+ }
1118
+ return {
1119
+ userId: c.candidateUserId,
1120
+ profile: {
1121
+ name: profile?.identity?.name,
1122
+ bio: profile?.identity?.bio,
1123
+ location: profile?.identity?.location,
1124
+ interests: profile?.attributes?.interests,
1125
+ skills: profile?.attributes?.skills,
1126
+ context: profile?.narrative?.context,
1127
+ },
1128
+ intents: c.candidateIntentId != null
1129
+ ? [{ intentId: c.candidateIntentId, payload: intentPayload ?? '', summary: intentSummary }]
1130
+ : undefined,
1131
+ indexId: c.indexId,
1132
+ ragScore: c.similarity * 100,
1133
+ matchedVia: c.lens,
1134
+ };
1135
+ }));
1136
+ const userIdToIndexId = new Map();
1137
+ for (const e of candidateEntities) {
1138
+ if (!userIdToIndexId.has(e.userId))
1139
+ userIdToIndexId.set(e.userId, e.indexId);
1140
+ }
1141
+ // Lower default threshold to 50 for better recall
1142
+ const minScore = state.options.minScore ?? 50;
1143
+ const evaluator = typeof evaluatorAgent.invokeEntityBundle === 'function'
1144
+ ? evaluatorAgent
1145
+ : new OpportunityEvaluator();
1146
+ const runParallel = process.env.RUN_OPPORTUNITY_EVAL_IN_PARALLEL === 'true';
1147
+ // Declare trace entries early so both parallel and serial paths can push error entries
1148
+ const traceEntries = [];
1149
+ const parallelErrors = [];
1150
+ let pairwiseOpportunities;
1151
+ if (runParallel) {
1152
+ // Experimental: one LLM call per candidate, all fired in parallel
1153
+ logger.verbose('[Graph:Evaluation] Running parallel evaluation', { candidates: candidateEntities.length });
1154
+ const parallelResults = await Promise.all(candidateEntities.map((candidateEntity) => {
1155
+ const input = {
1156
+ discovererId: discoveryUserId,
1157
+ entities: [sourceEntity, candidateEntity],
1158
+ existingOpportunities: state.options.existingOpportunities,
1159
+ ...(state.searchQuery?.trim() ? { discoveryQuery: state.searchQuery.trim() } : {}),
1160
+ };
1161
+ const _evalStart = Date.now();
1162
+ const _traceEmitter = requestContext.getStore()?.traceEmitter;
1163
+ _traceEmitter?.({ type: "agent_start", name: "opportunity-evaluator" });
1164
+ const _candidateName = candidateEntity.profile?.name ?? "Unknown";
1165
+ return evaluator.invokeEntityBundle(input, { minScore, returnAll: true })
1166
+ .then((res) => {
1167
+ const _evalDuration = Date.now() - _evalStart;
1168
+ agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
1169
+ const _topScore = res.length > 0 ? Math.max(...res.map(r => r.score)) : -1;
1170
+ const _summary = _topScore < 0 ? `${_candidateName}: no match` : `${_candidateName}: ${_topScore}`;
1171
+ _traceEmitter?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: _summary });
1172
+ return res;
1173
+ })
1174
+ .catch((err) => {
1175
+ const _evalDuration = Date.now() - _evalStart;
1176
+ const _errMsg = err instanceof Error ? err.message : String(err);
1177
+ agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
1178
+ _traceEmitter?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: `${_candidateName}: error — ${_errMsg}` });
1179
+ logger.warn('[Graph:Evaluation] Parallel eval failed for candidate', {
1180
+ candidateUserId: candidateEntity.userId,
1181
+ error: err,
1182
+ });
1183
+ parallelErrors.push({
1184
+ candidateUserId: candidateEntity.userId,
1185
+ candidateName: _candidateName,
1186
+ error: _errMsg,
1187
+ durationMs: _evalDuration,
1188
+ });
1189
+ return [];
1190
+ });
1191
+ }));
1192
+ // Each call is already pairwise (source + 1 candidate) — flatten directly
1193
+ pairwiseOpportunities = parallelResults.flat();
1194
+ // Record trace entries for candidates that failed during parallel evaluation
1195
+ if (parallelErrors.length > 0) {
1196
+ traceEntries.push({
1197
+ node: "evaluation_errors",
1198
+ detail: `${parallelErrors.length}/${candidateEntities.length} candidate evaluation(s) failed`,
1199
+ data: {
1200
+ failedCount: parallelErrors.length,
1201
+ totalCandidates: candidateEntities.length,
1202
+ errors: parallelErrors.map(e => ({
1203
+ candidateUserId: e.candidateUserId,
1204
+ candidateName: e.candidateName,
1205
+ error: e.error,
1206
+ durationMs: e.durationMs,
1207
+ })),
1208
+ },
1209
+ });
1210
+ }
1211
+ }
1212
+ else {
1213
+ // Default: single bundled LLM call with all candidates
1214
+ const entities = [sourceEntity, ...candidateEntities];
1215
+ const input = {
1216
+ discovererId: discoveryUserId,
1217
+ entities,
1218
+ existingOpportunities: state.options.existingOpportunities,
1219
+ ...(state.searchQuery?.trim() ? { discoveryQuery: state.searchQuery.trim() } : {}),
1220
+ };
1221
+ // Get ALL scored results for tracing (returnAll: true), filter for persistence later
1222
+ const _evalStart = Date.now();
1223
+ const _traceEmitterSerial = requestContext.getStore()?.traceEmitter;
1224
+ _traceEmitterSerial?.({ type: "agent_start", name: "opportunity-evaluator" });
1225
+ let opportunitiesWithActors;
1226
+ try {
1227
+ opportunitiesWithActors = await evaluator.invokeEntityBundle(input, { minScore, returnAll: true });
1228
+ const _evalDuration = Date.now() - _evalStart;
1229
+ agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
1230
+ _traceEmitterSerial?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: `Evaluated ${candidateEntities.length} candidate(s)` });
1231
+ }
1232
+ catch (serialErr) {
1233
+ const _evalDuration = Date.now() - _evalStart;
1234
+ const _errMsg = serialErr instanceof Error ? serialErr.message : String(serialErr);
1235
+ agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _evalDuration });
1236
+ _traceEmitterSerial?.({ type: "agent_end", name: "opportunity-evaluator", durationMs: _evalDuration, summary: `error — ${_errMsg}` });
1237
+ throw serialErr; // Re-throw for the outer catch to handle
1238
+ }
1239
+ // Split multi-actor evaluator results into pairwise (viewer + candidate).
1240
+ // Each persisted discovery opportunity should have exactly 2 actors.
1241
+ // When splitting, build per-candidate reasoning from entity data because
1242
+ // the shared reasoning typically describes only one candidate.
1243
+ pairwiseOpportunities = [];
1244
+ for (const op of opportunitiesWithActors) {
1245
+ const pairwiseSourceId = state.onBehalfOfUserId ?? state.userId;
1246
+ const nonViewerActors = op.actors.filter(a => a.userId !== pairwiseSourceId);
1247
+ if (nonViewerActors.length <= 1) {
1248
+ pairwiseOpportunities.push(op);
1249
+ }
1250
+ else {
1251
+ logger.warn('[Graph:Evaluation] Splitting multi-actor opportunity; LLM returned bundled actors instead of one-per-candidate', {
1252
+ actorCount: nonViewerActors.length,
1253
+ userIds: nonViewerActors.map(a => a.userId),
1254
+ });
1255
+ const viewerActor = op.actors.find(a => a.userId === pairwiseSourceId);
1256
+ for (const candidate of nonViewerActors) {
1257
+ const entity = candidateEntities.find(e => e.userId === candidate.userId);
1258
+ const candidateName = entity?.profile?.name ?? '';
1259
+ const reasoningLower = op.reasoning.toLowerCase();
1260
+ const mentionsCandidate = candidateName !== '' &&
1261
+ reasoningLower.includes(candidateName.toLowerCase());
1262
+ const mentionsOtherCandidate = nonViewerActors
1263
+ .filter((actor) => actor.userId !== candidate.userId)
1264
+ .map((actor) => candidateEntities.find((e) => e.userId === actor.userId)?.profile?.name?.toLowerCase())
1265
+ .some((name) => name != null && reasoningLower.includes(name));
1266
+ let reasoning;
1267
+ if (mentionsCandidate && !mentionsOtherCandidate) {
1268
+ reasoning = op.reasoning;
1269
+ }
1270
+ else if (entity?.profile) {
1271
+ const p = entity.profile;
1272
+ const parts = [p.name, p.bio].filter(Boolean);
1273
+ if (p.skills?.length)
1274
+ parts.push(`Skills: ${p.skills.join(', ')}`);
1275
+ if (p.interests?.length)
1276
+ parts.push(`Interests: ${p.interests.join(', ')}`);
1277
+ reasoning = parts.join('. ') || op.reasoning;
1278
+ }
1279
+ else {
1280
+ reasoning = op.reasoning;
1281
+ }
1282
+ pairwiseOpportunities.push({
1283
+ reasoning,
1284
+ score: op.score,
1285
+ actors: [
1286
+ viewerActor ?? { userId: pairwiseSourceId, role: 'patient', intentId: null },
1287
+ candidate,
1288
+ ],
1289
+ });
1290
+ }
1291
+ }
1292
+ }
1293
+ }
1294
+ const evaluatedOpportunities = pairwiseOpportunities.map((op) => ({
1295
+ reasoning: op.reasoning,
1296
+ score: op.score,
1297
+ actors: op.actors.map((a) => {
1298
+ const isSource = a.userId === discoveryUserId;
1299
+ if (isSource) {
1300
+ // Source actor inherits the counterpart's indexId (shared match context)
1301
+ const counterpart = op.actors.find((other) => other.userId !== a.userId);
1302
+ const counterpartIndexId = counterpart
1303
+ ? userIdToIndexId.get(counterpart.userId) ?? candidateEntities.find((e) => e.userId === counterpart.userId)?.indexId
1304
+ : undefined;
1305
+ return {
1306
+ userId: a.userId,
1307
+ role: a.role,
1308
+ intentId: a.intentId,
1309
+ indexId: counterpartIndexId ?? userIdToIndexId.get(a.userId) ?? '',
1310
+ };
1311
+ }
1312
+ return {
1313
+ userId: a.userId,
1314
+ role: a.role,
1315
+ intentId: a.intentId,
1316
+ indexId: userIdToIndexId.get(a.userId) ?? candidateEntities.find((e) => e.userId === a.userId)?.indexId,
1317
+ };
1318
+ }),
1319
+ }));
1320
+ const passed = evaluatedOpportunities.filter((o) => o.score >= minScore);
1321
+ logger.verbose('[Graph:Evaluation] Evaluation complete', {
1322
+ evaluatedCount: evaluatedOpportunities.length,
1323
+ passed: passed.length,
1324
+ });
1325
+ // Build detailed trace entries for each evaluated candidate
1326
+ // Threshold filter trace: how many candidates in this batch were above/below similarity threshold
1327
+ const aboveThreshold = batchToEvaluate.filter(c => c.similarity >= 0.40).length;
1328
+ const belowThreshold = batchToEvaluate.length - aboveThreshold;
1329
+ traceEntries.push({
1330
+ node: "threshold_filter",
1331
+ detail: `${aboveThreshold} above 0.40, ${belowThreshold} below (batch of ${batchToEvaluate.length})`,
1332
+ data: {
1333
+ aboveThreshold,
1334
+ belowThreshold,
1335
+ minScore: 0.40,
1336
+ batchSize: batchToEvaluate.length,
1337
+ },
1338
+ });
1339
+ // Create a map of evaluated candidates by userId for quick lookup.
1340
+ // Use discoveryUserId (which accounts for onBehalfOfUserId in introducer flow)
1341
+ // rather than state.userId (which is the introducer, not present in pairwise actors).
1342
+ const evaluatedByUserId = new Map();
1343
+ for (const opp of evaluatedOpportunities) {
1344
+ const candidateActor = opp.actors.find(a => a.userId !== discoveryUserId);
1345
+ if (candidateActor) {
1346
+ evaluatedByUserId.set(candidateActor.userId, { score: opp.score, reasoning: opp.reasoning });
1347
+ }
1348
+ }
1349
+ // Summary entry
1350
+ traceEntries.push({
1351
+ node: "evaluation",
1352
+ detail: `Evaluated ${candidateEntities.length} candidate(s) → ${passed.length} passed (min score ${minScore})`,
1353
+ data: {
1354
+ inputCandidates: batchToEvaluate.length,
1355
+ returnedFromEvaluator: evaluatedOpportunities.length,
1356
+ passedCount: passed.length,
1357
+ minScore,
1358
+ remaining: effectiveRemaining.length,
1359
+ batchNumber: 1,
1360
+ durationMs: Date.now() - startTime,
1361
+ model: getModelName("opportunityEvaluator"),
1362
+ },
1363
+ });
1364
+ // Individual candidate entries - show ALL candidates that went to evaluator
1365
+ for (const entity of candidateEntities) {
1366
+ const candidateName = entity.profile?.name || entity.userId.slice(0, 8);
1367
+ const candidateBio = entity.profile?.bio;
1368
+ const evaluated = evaluatedByUserId.get(entity.userId);
1369
+ const score = evaluated?.score;
1370
+ const reasoning = evaluated?.reasoning;
1371
+ const didPass = score !== undefined && score >= minScore;
1372
+ const status = score !== undefined
1373
+ ? (didPass ? '✓ passed' : `✗ score ${score}`)
1374
+ : '✗ not scored';
1375
+ traceEntries.push({
1376
+ node: "candidate",
1377
+ detail: `${candidateName}: ${status}`,
1378
+ data: {
1379
+ userId: entity.userId,
1380
+ name: candidateName,
1381
+ bio: candidateBio,
1382
+ score: score,
1383
+ passed: didPass,
1384
+ reasoning: reasoning || 'No evaluation returned for this candidate',
1385
+ matchedVia: entity.matchedVia,
1386
+ ragScore: entity.ragScore,
1387
+ model: getModelName("opportunityEvaluator"),
1388
+ intents: entity.intents?.map((i) => ({
1389
+ intentId: i.intentId,
1390
+ summary: (i.summary || i.payload || '').slice(0, 100),
1391
+ })),
1392
+ profile: entity.profile ? {
1393
+ name: entity.profile.name,
1394
+ location: entity.profile.location,
1395
+ } : undefined,
1396
+ },
1397
+ });
1398
+ }
1399
+ // Only pass opportunities that passed the threshold to downstream nodes
1400
+ const passedOpportunities = evaluatedOpportunities.filter((o) => o.score >= minScore);
1401
+ return {
1402
+ evaluatedOpportunities: passedOpportunities,
1403
+ remainingCandidates: effectiveRemaining,
1404
+ trace: traceEntries,
1405
+ agentTimings: agentTimingsAccum,
1406
+ };
1407
+ }
1408
+ catch (error) {
1409
+ const errMsg = error instanceof Error ? error.message : String(error);
1410
+ logger.error('[Graph:Evaluation] Failed', { error });
1411
+ return {
1412
+ evaluatedOpportunities: [],
1413
+ error: 'Failed to evaluate candidates.',
1414
+ trace: [{
1415
+ node: "evaluation_fatal",
1416
+ detail: `Evaluation failed: ${errMsg}`,
1417
+ data: {
1418
+ error: errMsg,
1419
+ candidateCount: state.candidates?.length ?? 0,
1420
+ durationMs: Date.now() - startTime,
1421
+ },
1422
+ }],
1423
+ agentTimings: agentTimingsAccum,
1424
+ };
1425
+ }
1426
+ });
1427
+ };
1428
+ /**
1429
+ * Node 3b: Negotiate
1430
+ * Runs bilateral negotiation between source user and each evaluated candidate.
1431
+ * Filters out candidates that fail to produce an opportunity; updates scores for those that pass.
1432
+ */
1433
+ const negotiateNode = async (state) => {
1434
+ if (!this.negotiationGraph)
1435
+ return {};
1436
+ const traceEmitter = requestContext.getStore()?.traceEmitter;
1437
+ const graphStart = Date.now();
1438
+ traceEmitter?.({ type: "graph_start", name: "Negotiation graph" });
1439
+ try {
1440
+ // Use the same discoveryUserId pattern as evaluationNode
1441
+ const discoveryUserId = (state.onBehalfOfUserId ?? state.userId);
1442
+ const sourceAccount = await this.database.getUser(discoveryUserId).catch(() => null);
1443
+ const sourceUser = {
1444
+ id: discoveryUserId,
1445
+ intents: state.indexedIntents?.slice(0, 5).map(i => ({
1446
+ id: i.intentId,
1447
+ title: i.summary ?? '',
1448
+ description: i.payload ?? '',
1449
+ confidence: 1,
1450
+ })) ?? [],
1451
+ profile: {
1452
+ name: state.sourceProfile?.identity?.name ?? sourceAccount?.name,
1453
+ bio: state.sourceProfile?.identity?.bio ?? sourceAccount?.intro ?? undefined,
1454
+ location: state.sourceProfile?.identity?.location ?? sourceAccount?.location ?? undefined,
1455
+ skills: state.sourceProfile?.attributes?.skills,
1456
+ interests: state.sourceProfile?.attributes?.interests,
1457
+ },
1458
+ };
1459
+ // Build candidates with enriched context from database.
1460
+ // Each actor carries its own indexId — use it for per-candidate index context.
1461
+ const candidateEntries = state.evaluatedOpportunities
1462
+ .map(opp => {
1463
+ const candidateActor = opp.actors.find(a => a.userId !== discoveryUserId);
1464
+ if (!candidateActor)
1465
+ return null;
1466
+ return { opp, candidateActor };
1467
+ })
1468
+ .filter((e) => e !== null);
1469
+ const candidates = await Promise.all(candidateEntries.map(async ({ opp, candidateActor }) => {
1470
+ const userId = candidateActor.userId;
1471
+ const [profile, user, activeIntents, intent] = await Promise.all([
1472
+ this.database.getProfile(userId).catch(() => null),
1473
+ this.database.getUser(userId).catch(() => null),
1474
+ this.database.getActiveIntents(userId).catch(() => []),
1475
+ candidateActor.intentId
1476
+ ? this.database.getIntent(candidateActor.intentId).catch(() => null)
1477
+ : null,
1478
+ ]);
1479
+ // Prefer active intents (capped at 5, trigger intent first); fall back to single intent.
1480
+ // If the trigger intent was archived but we fetched it by ID, prepend it so negotiation
1481
+ // always includes the intent that produced the opportunity match.
1482
+ const toNegIntent = (ai) => ({
1483
+ id: (ai.id ?? candidateActor.intentId),
1484
+ title: ai.summary ?? '',
1485
+ description: ai.payload ?? '',
1486
+ confidence: 1,
1487
+ });
1488
+ const triggerInActive = activeIntents.some(ai => ai.id === candidateActor.intentId);
1489
+ const triggerFallback = !triggerInActive && intent ? [toNegIntent(intent)] : [];
1490
+ const candidateIntents = [
1491
+ ...triggerFallback,
1492
+ ...activeIntents.filter(ai => ai.id === candidateActor.intentId).map(toNegIntent),
1493
+ ...activeIntents.filter(ai => ai.id !== candidateActor.intentId).map(toNegIntent),
1494
+ ].slice(0, 5);
1495
+ return {
1496
+ userId,
1497
+ score: opp.score,
1498
+ reasoning: opp.reasoning,
1499
+ valencyRole: candidateActor.role ?? 'peer',
1500
+ indexId: candidateActor.indexId,
1501
+ candidateUser: {
1502
+ id: userId,
1503
+ intents: candidateIntents,
1504
+ profile: {
1505
+ name: profile?.identity?.name ?? user?.name,
1506
+ bio: profile?.identity?.bio ?? user?.intro ?? undefined,
1507
+ location: profile?.identity?.location ?? user?.location ?? undefined,
1508
+ skills: profile?.attributes?.skills,
1509
+ interests: profile?.attributes?.interests,
1510
+ },
1511
+ },
1512
+ };
1513
+ }));
1514
+ const isChatPath = !!state.options?.conversationId;
1515
+ const maxTurns = isChatPath ? 4 : 6;
1516
+ // Fetch per-candidate index context (group by indexId to avoid duplicate lookups)
1517
+ const uniqueIndexIds = [...new Set(candidates.map(c => c.indexId).filter((id) => !!id))];
1518
+ const indexContextMap = new Map();
1519
+ await Promise.all(uniqueIndexIds.map(async (indexId) => {
1520
+ const ctx = await this.database.getIndexMemberContext(indexId, discoveryUserId).catch(() => null);
1521
+ const prompt = [ctx?.indexPrompt, ctx?.memberPrompt]
1522
+ .filter((v) => !!v?.trim())
1523
+ .join('\n\n');
1524
+ if (prompt)
1525
+ indexContextMap.set(indexId, prompt);
1526
+ }));
1527
+ // Run negotiations per candidate with their actual index context
1528
+ const acceptedResults = await negotiateCandidates(this.negotiationGraph, sourceUser, candidates, { indexId: '', prompt: '' }, // base context, overridden per-candidate below
1529
+ { maxTurns, traceEmitter: traceEmitter ?? undefined,
1530
+ indexContextOverrides: indexContextMap });
1531
+ // Filter opportunities to only those with an opportunity outcome, update scores
1532
+ const acceptedMap = new Map(acceptedResults.map(r => [r.userId, r]));
1533
+ const updatedOpportunities = state.evaluatedOpportunities
1534
+ .filter(opp => {
1535
+ const candidateActor = opp.actors.find(a => a.userId !== discoveryUserId);
1536
+ return candidateActor && acceptedMap.has(candidateActor.userId);
1537
+ })
1538
+ .map(opp => {
1539
+ const candidateActor = opp.actors.find(a => a.userId !== discoveryUserId);
1540
+ const negResult = candidateActor && acceptedMap.get(candidateActor.userId);
1541
+ return negResult ? { ...opp, score: negResult.negotiationScore } : opp;
1542
+ });
1543
+ traceEmitter?.({ type: "graph_end", name: "Negotiation graph", durationMs: Date.now() - graphStart });
1544
+ return { evaluatedOpportunities: updatedOpportunities };
1545
+ }
1546
+ catch (err) {
1547
+ logger.error("[Graph:Negotiate] Negotiation stage failed", { error: err });
1548
+ traceEmitter?.({ type: "graph_end", name: "Negotiation graph", durationMs: Date.now() - graphStart });
1549
+ return { evaluatedOpportunities: [] };
1550
+ }
1551
+ };
1552
+ /**
1553
+ * Node 4: Ranking
1554
+ * Sorts evaluated opportunities by score, applies limit, dedupes by actor-set hash.
1555
+ */
1556
+ const rankingNode = withNodeTrace("opportunity-ranking", async (state) => {
1557
+ return timed("OpportunityGraph.ranking", async () => {
1558
+ logger.verbose('[Graph:Ranking] Starting ranking', {
1559
+ evaluatedCount: state.evaluatedOpportunities.length,
1560
+ });
1561
+ try {
1562
+ const sorted = [...state.evaluatedOpportunities].sort((a, b) => b.score - a.score);
1563
+ const limit = state.options.limit ?? 20;
1564
+ const ranked = sorted.slice(0, limit);
1565
+ const actorSetKey = (opp) => opp.actors
1566
+ .map((a) => `${a.userId}:${a.indexId}`)
1567
+ .sort()
1568
+ .join('|');
1569
+ const seen = new Set();
1570
+ const deduplicated = ranked.filter((opp) => {
1571
+ const key = actorSetKey(opp);
1572
+ if (seen.has(key))
1573
+ return false;
1574
+ seen.add(key);
1575
+ return true;
1576
+ });
1577
+ logger.verbose('[Graph:Ranking] Ranking complete', {
1578
+ sorted: sorted.length,
1579
+ afterLimit: ranked.length,
1580
+ afterDedup: deduplicated.length,
1581
+ });
1582
+ return { evaluatedOpportunities: deduplicated };
1583
+ }
1584
+ catch (error) {
1585
+ const errMsg = error instanceof Error ? error.message : String(error);
1586
+ logger.error('[Graph:Ranking] Failed', { error });
1587
+ return {
1588
+ evaluatedOpportunities: [],
1589
+ error: 'Failed to rank opportunities.',
1590
+ trace: [{
1591
+ node: "ranking_fatal",
1592
+ detail: `Ranking failed: ${errMsg}`,
1593
+ data: { error: errMsg },
1594
+ }],
1595
+ };
1596
+ }
1597
+ });
1598
+ }, (result) => {
1599
+ const r = result;
1600
+ if (r?.error)
1601
+ return `error: ${r.error}`;
1602
+ const opps = r?.evaluatedOpportunities;
1603
+ return opps ? `Ranked ${opps.length} opportunity(ies)` : undefined;
1604
+ });
1605
+ /**
1606
+ * Node: intro_validation (create_introduction path)
1607
+ * Validates index scope, membership for introducer and all party users, and no existing opportunity.
1608
+ */
1609
+ const introValidationNode = async (state) => {
1610
+ return timed("OpportunityGraph.introValidation", async () => {
1611
+ logger.verbose('[Graph:IntroValidation] Starting', {
1612
+ userId: state.userId,
1613
+ indexId: state.indexId,
1614
+ entitiesCount: state.introductionEntities?.length ?? 0,
1615
+ });
1616
+ try {
1617
+ const entities = state.introductionEntities ?? [];
1618
+ const primaryIndexId = (state.indexId ?? entities[0]?.indexId);
1619
+ const partyUserIds = [...new Set(entities.map((e) => e.userId).filter((id) => id !== state.userId))];
1620
+ if (!primaryIndexId || partyUserIds.length < 1) {
1621
+ return {
1622
+ error: 'Introduction requires indexId and at least two entities (introducer + one counterpart).',
1623
+ };
1624
+ }
1625
+ if (state.requiredIndexId && primaryIndexId !== state.requiredIndexId) {
1626
+ return {
1627
+ error: 'This chat is scoped to a different community. You can only introduce members of the current community.',
1628
+ };
1629
+ }
1630
+ const introducerIsMember = await this.database.isIndexMember(primaryIndexId, state.userId);
1631
+ if (!introducerIsMember) {
1632
+ return {
1633
+ error: 'One or more users are not members of the specified community. You can only introduce members who share an index.',
1634
+ };
1635
+ }
1636
+ const partyMemberships = await Promise.all(partyUserIds.map((userId) => this.database.isIndexMember(primaryIndexId, userId)));
1637
+ const allPartyMembers = partyMemberships.every(Boolean);
1638
+ if (!allPartyMembers) {
1639
+ return {
1640
+ error: 'One or more users are not members of the specified community. You can only introduce members who share an index.',
1641
+ };
1642
+ }
1643
+ const exists = await this.database.opportunityExistsBetweenActors(partyUserIds, primaryIndexId);
1644
+ if (exists) {
1645
+ return { error: 'An opportunity already exists between these people.' };
1646
+ }
1647
+ logger.verbose('[Graph:IntroValidation] Validation passed');
1648
+ return {};
1649
+ }
1650
+ catch (err) {
1651
+ const errMsg = err instanceof Error ? err.message : String(err);
1652
+ logger.error('[Graph:IntroValidation] Failed', {
1653
+ userId: state.userId,
1654
+ indexId: state.indexId,
1655
+ error: err,
1656
+ });
1657
+ return {
1658
+ error: 'Introduction validation failed.',
1659
+ trace: [{
1660
+ node: "intro_validation_fatal",
1661
+ detail: `IntroValidation failed: ${errMsg}`,
1662
+ data: { error: errMsg },
1663
+ }],
1664
+ };
1665
+ }
1666
+ });
1667
+ };
1668
+ /**
1669
+ * Build fallback reasoning and actors when evaluator returns empty or throws.
1670
+ */
1671
+ function buildIntroFallback(entities, state, primaryIndexId, introducerName) {
1672
+ const reasoning = `${introducerName ?? 'A member'} believes these people should connect.` +
1673
+ (state.introductionHint ? ` Context: ${state.introductionHint}` : '');
1674
+ const score = 70;
1675
+ const partyUserIds = entities.map((e) => e.userId).filter((id) => id !== state.userId);
1676
+ const actors = partyUserIds.map((uid) => ({
1677
+ userId: uid,
1678
+ role: 'peer',
1679
+ indexId: primaryIndexId,
1680
+ }));
1681
+ return { reasoning, score, actors };
1682
+ }
1683
+ /**
1684
+ * Node: intro_evaluation (create_introduction path)
1685
+ * Runs entity-bundle evaluator and sets evaluatedOpportunities (one) + introductionContext.
1686
+ */
1687
+ const introEvaluationNode = async (state) => {
1688
+ return timed("OpportunityGraph.introEvaluation", async () => {
1689
+ logger.verbose('[Graph:IntroEvaluation] Starting', { userId: state.userId });
1690
+ if (state.error) {
1691
+ return { evaluatedOpportunities: [], agentTimings: [] };
1692
+ }
1693
+ const entities = state.introductionEntities ?? [];
1694
+ const primaryIndexId = (state.indexId ?? entities[0]?.indexId);
1695
+ if (!primaryIndexId || entities.length < 2) {
1696
+ return { evaluatedOpportunities: [], error: 'Missing entities or index for introduction.', agentTimings: [] };
1697
+ }
1698
+ const agentTimingsAccum = [];
1699
+ let introducerName;
1700
+ let reasoning;
1701
+ let score;
1702
+ let actors = [];
1703
+ const _traceEmitterIntro = requestContext.getStore()?.traceEmitter;
1704
+ let _introEvalStarted = false;
1705
+ let _evalStart = Date.now();
1706
+ try {
1707
+ const introducerUser = await this.database.getUser(state.userId);
1708
+ introducerName = introducerUser?.name ?? undefined;
1709
+ const input = {
1710
+ discovererId: state.userId,
1711
+ entities,
1712
+ introductionMode: true,
1713
+ introducerName,
1714
+ introductionHint: state.introductionHint ?? undefined,
1715
+ };
1716
+ _evalStart = Date.now();
1717
+ _traceEmitterIntro?.({ type: "agent_start", name: "intro-evaluator" });
1718
+ _introEvalStarted = true;
1719
+ const evaluated = await evaluatorAgent.invokeEntityBundle(input, { minScore: 0 });
1720
+ const _introDuration = Date.now() - _evalStart;
1721
+ agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _introDuration });
1722
+ _traceEmitterIntro?.({ type: "agent_end", name: "intro-evaluator", durationMs: _introDuration, summary: "Evaluated introduction" });
1723
+ if (evaluated.length > 0) {
1724
+ const best = evaluated[0];
1725
+ reasoning = best.reasoning;
1726
+ score = best.score;
1727
+ actors = best.actors.map((a) => ({
1728
+ userId: a.userId,
1729
+ role: a.role,
1730
+ intentId: a.intentId ?? undefined,
1731
+ indexId: primaryIndexId,
1732
+ }));
1733
+ }
1734
+ else {
1735
+ const fallback = buildIntroFallback(entities, state, primaryIndexId, introducerName);
1736
+ reasoning = fallback.reasoning;
1737
+ score = fallback.score;
1738
+ actors = fallback.actors;
1739
+ }
1740
+ }
1741
+ catch (evalErr) {
1742
+ const errMsg = evalErr instanceof Error ? evalErr.message : String(evalErr);
1743
+ // Close the intro-evaluator span if it was started before the error
1744
+ if (_introEvalStarted) {
1745
+ const _introErrDuration = Date.now() - _evalStart;
1746
+ _traceEmitterIntro?.({ type: "agent_end", name: "intro-evaluator", durationMs: _introErrDuration, summary: `error — ${errMsg}` });
1747
+ agentTimingsAccum.push({ name: 'opportunity.evaluator', durationMs: _introErrDuration });
1748
+ }
1749
+ logger.warn('[Graph:IntroEvaluation] Evaluator or getUser failed, using fallback', { error: evalErr });
1750
+ const fallback = buildIntroFallback(entities, state, primaryIndexId, introducerName);
1751
+ reasoning = fallback.reasoning;
1752
+ score = fallback.score;
1753
+ actors = fallback.actors;
1754
+ return {
1755
+ evaluatedOpportunities: [{ actors, score, reasoning }],
1756
+ introductionContext: { createdByName: introducerName },
1757
+ options: { ...state.options, initialStatus: state.options.initialStatus ?? 'latent' },
1758
+ agentTimings: agentTimingsAccum,
1759
+ trace: [{
1760
+ node: "intro_evaluation_fatal",
1761
+ detail: `IntroEvaluation failed (using fallback): ${errMsg}`,
1762
+ data: { error: errMsg },
1763
+ }],
1764
+ };
1765
+ }
1766
+ const evaluatedOpportunity = {
1767
+ actors,
1768
+ score,
1769
+ reasoning,
1770
+ };
1771
+ return {
1772
+ evaluatedOpportunities: [evaluatedOpportunity],
1773
+ introductionContext: { createdByName: introducerName },
1774
+ options: { ...state.options, initialStatus: state.options.initialStatus ?? 'latent' },
1775
+ agentTimings: agentTimingsAccum,
1776
+ };
1777
+ });
1778
+ };
1779
+ /**
1780
+ * Node 5: Persist
1781
+ * Creates opportunities from evaluator-proposed actors (indexId, userId, role, optional intent).
1782
+ */
1783
+ const persistNode = withNodeTrace("opportunity-persist", async (state) => {
1784
+ return timed("OpportunityGraph.persist", async () => {
1785
+ const startTime = Date.now();
1786
+ logger.verbose('[Graph:Persist] Starting persistence (dedup-v2)', {
1787
+ opportunitiesToCreate: state.evaluatedOpportunities.length,
1788
+ initialStatus: state.options.initialStatus ?? 'pending',
1789
+ });
1790
+ if (state.evaluatedOpportunities.length === 0) {
1791
+ logger.verbose('[Graph:Persist] No opportunities to persist');
1792
+ return { opportunities: [] };
1793
+ }
1794
+ try {
1795
+ const itemsToPersist = [];
1796
+ const reactivatedOpportunities = [];
1797
+ const existingBetweenActors = [];
1798
+ const now = new Date().toISOString();
1799
+ const initialStatus = state.options.initialStatus ?? 'pending';
1800
+ // Only skip 'draft' (chat-only) opportunities during dedup.
1801
+ // 'latent' must NOT be skipped — background discovery creates latent opportunities,
1802
+ // and excluding them causes the same user pair to get duplicate opportunities
1803
+ // when multiple intents trigger separate discovery jobs (IND-166).
1804
+ const DEDUP_SKIP_STATUSES = ['draft'];
1805
+ const introducerUserForOnBehalf = state.onBehalfOfUserId
1806
+ ? await this.database.getUser(state.userId)
1807
+ : null;
1808
+ for (const evaluated of state.evaluatedOpportunities) {
1809
+ const indexIdForActors = state.indexId ?? evaluated.actors[0]?.indexId;
1810
+ let actors;
1811
+ let data;
1812
+ logger.verbose('[Graph:Persist:PathSelect]', {
1813
+ isIntroduction: !!state.introductionContext,
1814
+ stateUserId: state.userId,
1815
+ stateIndexId: state.indexId,
1816
+ evaluatedActorUserIds: evaluated.actors.map(a => a.userId),
1817
+ });
1818
+ if (state.introductionContext) {
1819
+ if (indexIdForActors === undefined) {
1820
+ logger.warn('[Graph:Persist] Introduction path missing indexId; skipping opportunity', {
1821
+ userId: state.userId,
1822
+ actorsCount: evaluated.actors.length,
1823
+ });
1824
+ continue;
1825
+ }
1826
+ // Introduction path: manual detection, introducer actor, curator_judgment signal.
1827
+ const evaluatorActors = evaluated.actors.map((a) => ({
1828
+ indexId: a.indexId ?? indexIdForActors,
1829
+ userId: a.userId,
1830
+ role: a.role,
1831
+ ...(a.intentId ? { intent: a.intentId } : {}),
1832
+ }));
1833
+ const viewerAlreadyInActors = evaluatorActors.some(a => a.userId === state.userId);
1834
+ actors = viewerAlreadyInActors
1835
+ ? evaluatorActors
1836
+ : [
1837
+ ...evaluatorActors,
1838
+ { indexId: indexIdForActors, userId: state.userId, role: 'introducer' },
1839
+ ];
1840
+ data = {
1841
+ detection: {
1842
+ source: 'manual',
1843
+ createdBy: state.userId,
1844
+ createdByName: state.introductionContext.createdByName,
1845
+ timestamp: now,
1846
+ },
1847
+ actors,
1848
+ interpretation: {
1849
+ category: 'collaboration',
1850
+ reasoning: evaluated.reasoning,
1851
+ confidence: evaluated.score / 100,
1852
+ signals: [
1853
+ {
1854
+ type: 'curator_judgment',
1855
+ weight: 1,
1856
+ detail: `Introduction by ${state.introductionContext.createdByName ?? 'a member'} via chat`,
1857
+ },
1858
+ ],
1859
+ },
1860
+ context: {
1861
+ indexId: state.indexId ?? indexIdForActors,
1862
+ ...(state.options.conversationId ? { conversationId: state.options.conversationId } : {}),
1863
+ },
1864
+ confidence: String(evaluated.score / 100),
1865
+ status: initialStatus,
1866
+ };
1867
+ }
1868
+ else if (state.onBehalfOfUserId) {
1869
+ if (indexIdForActors === undefined) {
1870
+ logger.warn('[Graph:Persist] Introducer discovery path missing indexId; skipping opportunity', {
1871
+ userId: state.userId,
1872
+ actorsCount: evaluated.actors.length,
1873
+ });
1874
+ continue;
1875
+ }
1876
+ // Introducer discovery path: manual detection, introducer is state.userId, target is onBehalfOfUserId.
1877
+ const evaluatorActors = evaluated.actors.map((a) => ({
1878
+ indexId: a.indexId ?? indexIdForActors,
1879
+ userId: a.userId,
1880
+ role: a.role,
1881
+ ...(a.intentId ? { intent: a.intentId } : {}),
1882
+ }));
1883
+ const viewerAlreadyInActors = evaluatorActors.some(a => a.userId === state.userId);
1884
+ actors = viewerAlreadyInActors
1885
+ ? evaluatorActors
1886
+ : [
1887
+ ...evaluatorActors,
1888
+ { indexId: indexIdForActors, userId: state.userId, role: 'introducer' },
1889
+ ];
1890
+ const candidateUserId = evaluated.actors.find((a) => a.userId !== state.onBehalfOfUserId)?.userId;
1891
+ const overlapping = candidateUserId
1892
+ ? await this.database.findOverlappingOpportunities([state.onBehalfOfUserId, candidateUserId], { excludeStatuses: DEDUP_SKIP_STATUSES })
1893
+ : [];
1894
+ if (overlapping.length > 0) {
1895
+ const existing = overlapping[0];
1896
+ const sameIntroducer = existing.actors?.some((actor) => actor.role === 'introducer' && actor.userId === state.userId);
1897
+ if (existing.status === 'expired' && sameIntroducer) {
1898
+ const reactivated = await this.database.updateOpportunityStatus(existing.id, 'draft');
1899
+ if (reactivated)
1900
+ reactivatedOpportunities.push(reactivated);
1901
+ continue;
1902
+ }
1903
+ if (existing.status === 'latent') {
1904
+ // Upgrade latent to draft for introduction path
1905
+ const upgraded = await this.database.updateOpportunityStatus(existing.id, 'draft');
1906
+ if (upgraded) {
1907
+ logger.verbose('[Graph:Persist] Upgraded latent opportunity to draft (introduction path)', {
1908
+ opportunityId: existing.id,
1909
+ candidateUserId,
1910
+ });
1911
+ reactivatedOpportunities.push(upgraded);
1912
+ }
1913
+ continue;
1914
+ }
1915
+ if (existing.status !== 'expired' && candidateUserId) {
1916
+ existingBetweenActors.push({
1917
+ candidateUserId: candidateUserId,
1918
+ indexId: (state.indexId ?? indexIdForActors ?? ''),
1919
+ existingOpportunityId: existing.id,
1920
+ existingStatus: existing.status,
1921
+ });
1922
+ continue;
1923
+ }
1924
+ }
1925
+ data = {
1926
+ detection: {
1927
+ source: 'manual',
1928
+ createdBy: state.userId,
1929
+ createdByName: introducerUserForOnBehalf?.name ?? undefined,
1930
+ timestamp: now,
1931
+ },
1932
+ actors,
1933
+ interpretation: {
1934
+ category: 'collaboration',
1935
+ reasoning: evaluated.reasoning,
1936
+ confidence: evaluated.score / 100,
1937
+ signals: [{
1938
+ type: 'curator_judgment',
1939
+ weight: 1,
1940
+ detail: `Discovery on behalf of another user by ${introducerUserForOnBehalf?.name ?? 'a member'} via chat`,
1941
+ }],
1942
+ },
1943
+ context: {
1944
+ indexId: state.indexId ?? indexIdForActors,
1945
+ ...(state.options.conversationId ? { conversationId: state.options.conversationId } : {}),
1946
+ },
1947
+ confidence: String(evaluated.score / 100),
1948
+ status: initialStatus,
1949
+ };
1950
+ }
1951
+ else {
1952
+ // Discovery path: opportunity_graph source, no introducer, lifecycle guard for agent/patient.
1953
+ const evaluatorActors = evaluated.actors.map((a) => ({
1954
+ indexId: a.indexId ?? indexIdForActors,
1955
+ userId: a.userId,
1956
+ role: a.role,
1957
+ ...(a.intentId ? { intent: a.intentId } : {}),
1958
+ }));
1959
+ actors = evaluatorActors;
1960
+ const hasIntroducerActor = actors.some(a => a.role === 'introducer');
1961
+ if (!hasIntroducerActor) {
1962
+ const discovererIdx = actors.findIndex(a => a.userId === state.userId);
1963
+ if (discovererIdx >= 0 && actors[discovererIdx].role === 'agent') {
1964
+ const counterpartIdx = actors.findIndex((a, i) => i !== discovererIdx && a.role === 'patient');
1965
+ actors[discovererIdx] = { ...actors[discovererIdx], role: 'patient' };
1966
+ if (counterpartIdx >= 0) {
1967
+ actors[counterpartIdx] = { ...actors[counterpartIdx], role: 'agent' };
1968
+ }
1969
+ logger.verbose('[Graph:Persist] Swapped discoverer from agent to patient for lifecycle visibility', {
1970
+ discovererId: state.userId,
1971
+ });
1972
+ }
1973
+ }
1974
+ // Index-agnostic dedup: find ANY existing opportunity between these users,
1975
+ // regardless of which index it was created in or whether context.indexId is set.
1976
+ const candidateUserId = evaluated.actors.find((a) => a.userId !== state.userId)?.userId;
1977
+ logger.verbose('[Graph:Persist:Dedup] Checking overlapping opportunities', {
1978
+ stateUserId: state.userId,
1979
+ candidateUserId: candidateUserId ?? 'NONE',
1980
+ evaluatedActors: evaluated.actors.map(a => ({ userId: a.userId, role: a.role })),
1981
+ });
1982
+ const overlapping = candidateUserId
1983
+ ? await this.database.findOverlappingOpportunities([state.userId, candidateUserId], { excludeStatuses: DEDUP_SKIP_STATUSES })
1984
+ : [];
1985
+ logger.verbose('[Graph:Persist:Dedup] findOverlappingOpportunities result', {
1986
+ count: overlapping.length,
1987
+ results: overlapping.map(o => ({ id: o.id, status: o.status, actors: o.actors?.map((a) => ({ userId: a.userId, role: a.role })) })),
1988
+ });
1989
+ if (overlapping.length > 0) {
1990
+ const existing = overlapping[0];
1991
+ const existingIndexId = (existing.context?.indexId ?? state.indexId ?? state.userIndexes?.[0] ?? '');
1992
+ if (existing.status === 'expired') {
1993
+ const reactivated = await this.database.updateOpportunityStatus(existing.id, initialStatus);
1994
+ if (reactivated) {
1995
+ logger.verbose('[Graph:Persist] Reactivated expired opportunity', {
1996
+ opportunityId: existing.id,
1997
+ candidateUserId,
1998
+ newStatus: initialStatus,
1999
+ });
2000
+ reactivatedOpportunities.push(reactivated);
2001
+ }
2002
+ }
2003
+ else if (existing.status === 'latent' && initialStatus !== 'latent') {
2004
+ // Upgrade latent (background-discovered) to the higher-priority status (e.g. pending)
2005
+ const upgraded = await this.database.updateOpportunityStatus(existing.id, initialStatus);
2006
+ if (upgraded) {
2007
+ logger.verbose('[Graph:Persist] Upgraded latent opportunity to higher-priority status', {
2008
+ opportunityId: existing.id,
2009
+ candidateUserId,
2010
+ previousStatus: 'latent',
2011
+ newStatus: initialStatus,
2012
+ });
2013
+ reactivatedOpportunities.push(upgraded);
2014
+ }
2015
+ }
2016
+ else if (candidateUserId) {
2017
+ existingBetweenActors.push({
2018
+ candidateUserId: candidateUserId,
2019
+ indexId: existingIndexId,
2020
+ existingOpportunityId: existing.id,
2021
+ existingStatus: existing.status,
2022
+ });
2023
+ logger.verbose('[Graph:Persist] Skipping duplicate; opportunity already exists between actors', {
2024
+ candidateUserId,
2025
+ existingStatus: existing.status,
2026
+ existingOpportunityId: existing.id,
2027
+ });
2028
+ }
2029
+ continue;
2030
+ }
2031
+ data = {
2032
+ detection: {
2033
+ source: 'opportunity_graph',
2034
+ createdBy: 'agent-opportunity-finder',
2035
+ ...(state.discoverySource === 'intent' && state.resolvedTriggerIntentId
2036
+ ? { triggeredBy: state.resolvedTriggerIntentId }
2037
+ : {}),
2038
+ timestamp: now,
2039
+ },
2040
+ actors,
2041
+ interpretation: {
2042
+ category: 'collaboration',
2043
+ reasoning: evaluated.reasoning,
2044
+ confidence: evaluated.score / 100,
2045
+ signals: [
2046
+ {
2047
+ type: evaluated.actors.some((a) => a.intentId) ? 'intent_match' : 'profile_match',
2048
+ weight: evaluated.score / 100,
2049
+ detail: 'Entity-bundle evaluator',
2050
+ },
2051
+ ],
2052
+ },
2053
+ context: {
2054
+ ...(state.indexId ? { indexId: state.indexId } : {}),
2055
+ ...(state.options.conversationId ? { conversationId: state.options.conversationId } : {}),
2056
+ },
2057
+ confidence: String(evaluated.score / 100),
2058
+ status: initialStatus,
2059
+ };
2060
+ }
2061
+ try {
2062
+ validateOpportunityActors(data.actors);
2063
+ }
2064
+ catch (err) {
2065
+ logger.warn('[Graph:Persist] Skipping opportunity with invalid actors', {
2066
+ error: err instanceof Error ? err.message : String(err),
2067
+ opportunityReasoning: evaluated.reasoning?.slice(0, 80),
2068
+ });
2069
+ continue;
2070
+ }
2071
+ itemsToPersist.push(data);
2072
+ }
2073
+ const { created: createdList } = await persistOpportunities({
2074
+ database: this.database,
2075
+ embedder: this.embedder,
2076
+ items: itemsToPersist,
2077
+ });
2078
+ const allOpportunities = [...reactivatedOpportunities, ...createdList];
2079
+ logger.verbose('[Graph:Persist] Persistence complete', {
2080
+ created: createdList.length,
2081
+ reactivated: reactivatedOpportunities.length,
2082
+ existingBetweenActorsCount: existingBetweenActors.length,
2083
+ status: initialStatus,
2084
+ });
2085
+ return {
2086
+ opportunities: allOpportunities,
2087
+ existingBetweenActors,
2088
+ trace: [{
2089
+ node: "persist",
2090
+ detail: `Created ${createdList.length}, reactivated ${reactivatedOpportunities.length}, ${existingBetweenActors.length} existing skipped`,
2091
+ data: {
2092
+ created: createdList.length,
2093
+ reactivated: reactivatedOpportunities.length,
2094
+ existingSkipped: existingBetweenActors.length,
2095
+ totalOutput: allOpportunities.length,
2096
+ durationMs: Date.now() - startTime,
2097
+ },
2098
+ }],
2099
+ };
2100
+ }
2101
+ catch (error) {
2102
+ const errMsg = error instanceof Error ? error.message : String(error);
2103
+ logger.error('[Graph:Persist] Failed', { error });
2104
+ return {
2105
+ opportunities: [],
2106
+ existingBetweenActors: [],
2107
+ error: 'Failed to persist opportunities.',
2108
+ trace: [{
2109
+ node: "persist_fatal",
2110
+ detail: `Persist failed: ${errMsg}`,
2111
+ data: { error: errMsg },
2112
+ }],
2113
+ };
2114
+ }
2115
+ });
2116
+ }, (result) => {
2117
+ const r = result;
2118
+ if (r?.error)
2119
+ return `error: ${r.error}`;
2120
+ const opps = r?.opportunities;
2121
+ return opps ? `Persisted ${opps.length} opportunity(ies)` : undefined;
2122
+ });
2123
+ // ═══════════════════════════════════════════════════════════════
2124
+ // CRUD NODES (read, update, delete, send)
2125
+ // ═══════════════════════════════════════════════════════════════
2126
+ /**
2127
+ * Read Node: List opportunities for the user, optionally filtered by indexId.
2128
+ * Fast path — no LLM calls.
2129
+ */
2130
+ const readNode = async (state) => {
2131
+ return timed("OpportunityGraph.read", async () => {
2132
+ logger.verbose('[Graph:Read] Listing opportunities', {
2133
+ userId: state.userId,
2134
+ indexId: state.indexId,
2135
+ });
2136
+ try {
2137
+ let indexIdFilter;
2138
+ if (state.indexId) {
2139
+ const isMember = await this.database.isIndexMember(state.indexId, state.userId);
2140
+ if (!isMember) {
2141
+ return {
2142
+ readResult: { count: 0, opportunities: [], message: 'Index not found or you are not a member.' },
2143
+ };
2144
+ }
2145
+ indexIdFilter = state.indexId;
2146
+ }
2147
+ const rawList = await this.database.getOpportunitiesForUser(state.userId, {
2148
+ limit: 30,
2149
+ ...(indexIdFilter ? { indexId: indexIdFilter } : {}),
2150
+ });
2151
+ const list = rawList.filter((opp) => opp.status !== 'expired');
2152
+ if (list.length === 0) {
2153
+ return {
2154
+ readResult: {
2155
+ count: 0,
2156
+ message: 'You have no opportunities yet. Use create_opportunities to search for connections.',
2157
+ opportunities: [],
2158
+ },
2159
+ };
2160
+ }
2161
+ // Dedupe by counterpart set (same people = one row) so chat does not show "You and X" per index
2162
+ const counterpartKey = (opp) => opp.actors
2163
+ .filter((a) => a.userId !== state.userId && a.role !== 'introducer')
2164
+ .map((a) => a.userId)
2165
+ .sort()
2166
+ .join(',');
2167
+ const byKey = new Map();
2168
+ for (const opp of list) {
2169
+ const key = counterpartKey(opp);
2170
+ const existing = byKey.get(key);
2171
+ const conf = Number(opp.interpretation?.confidence ?? opp.confidence ?? 0);
2172
+ const existingConf = existing ? Number(existing.interpretation?.confidence ?? existing.confidence ?? 0) : 0;
2173
+ const oppTime = opp.updatedAt instanceof Date ? opp.updatedAt.getTime() : new Date(opp.updatedAt).getTime();
2174
+ const existingTime = existing
2175
+ ? (existing.updatedAt instanceof Date ? existing.updatedAt.getTime() : new Date(existing.updatedAt).getTime())
2176
+ : 0;
2177
+ if (!existing || conf > existingConf || (conf === existingConf && oppTime > existingTime)) {
2178
+ byKey.set(key, opp);
2179
+ }
2180
+ }
2181
+ const dedupedList = [...byKey.values()];
2182
+ const sourceLabel = {
2183
+ chat: 'Suggested in chat',
2184
+ opportunity_graph: 'System match',
2185
+ manual: 'Manual',
2186
+ cron: 'Scheduled',
2187
+ member_added: 'Member added',
2188
+ };
2189
+ const enriched = await Promise.all(dedupedList.map(async (opp) => {
2190
+ // "Other parties" = all actors who are not the current user (exclude introducer for suggestedBy).
2191
+ // Opportunity graph persists roles as 'agent'|'patient'|'peer'; manual/createManual use 'party'.
2192
+ const otherParties = opp.actors.filter((a) => a.userId !== state.userId && a.role !== 'introducer');
2193
+ const introducer = opp.actors.find((a) => a.role === 'introducer');
2194
+ const partyIds = otherParties.map((a) => a.userId);
2195
+ const idsToResolve = introducer ? [...partyIds, introducer.userId] : partyIds;
2196
+ // Use the counterpart's (non-viewer) indexId — it reflects where the match was found.
2197
+ // actors[0] is typically the viewer with an arbitrary first-target-index value.
2198
+ const counterpartActor = opp.actors.find((a) => a.userId !== state.userId);
2199
+ const actorIndexId = counterpartActor?.indexId ?? opp.actors[0]?.indexId;
2200
+ const [indexRecord, ...profileAndUserPairs] = await Promise.all([
2201
+ actorIndexId ? this.database.getIndex(actorIndexId) : Promise.resolve(null),
2202
+ ...idsToResolve.map(async (uid) => {
2203
+ const [profile, user] = await Promise.all([
2204
+ this.database.getProfile(uid),
2205
+ this.database.getUser(uid),
2206
+ ]);
2207
+ return (profile?.identity?.name ?? user?.name ?? 'Unknown');
2208
+ }),
2209
+ ]);
2210
+ const connectedWith = profileAndUserPairs.slice(0, partyIds.length);
2211
+ const suggestedBy = introducer ? profileAndUserPairs[partyIds.length] ?? null : null;
2212
+ const category = opp.interpretation?.category ?? 'connection';
2213
+ const confidence = opp.interpretation?.confidence ?? (opp.confidence ? Number(opp.confidence) : null);
2214
+ const source = opp.detection?.source ? (sourceLabel[opp.detection.source] ?? opp.detection.source) : null;
2215
+ return {
2216
+ id: opp.id,
2217
+ indexName: indexRecord?.title ?? (actorIndexId ?? ''),
2218
+ connectedWith,
2219
+ suggestedBy,
2220
+ reasoning: opp.interpretation?.reasoning ?? 'Connection opportunity',
2221
+ status: opp.status,
2222
+ category,
2223
+ confidence: confidence != null ? confidence : null,
2224
+ source,
2225
+ };
2226
+ }));
2227
+ return {
2228
+ readResult: {
2229
+ count: enriched.length,
2230
+ message: `You have ${enriched.length} opportunity(ies).`,
2231
+ opportunities: enriched,
2232
+ },
2233
+ };
2234
+ }
2235
+ catch (err) {
2236
+ logger.error('[Graph:Read] Failed', { error: err });
2237
+ return {
2238
+ readResult: { count: 0, opportunities: [], message: 'Failed to list opportunities.' },
2239
+ };
2240
+ }
2241
+ });
2242
+ };
2243
+ /**
2244
+ * Update Node: Change opportunity status (accept, reject, etc.).
2245
+ */
2246
+ const updateNode = async (state) => {
2247
+ return timed("OpportunityGraph.update", async () => {
2248
+ logger.verbose('[Graph:Update] Updating opportunity status', {
2249
+ userId: state.userId,
2250
+ opportunityId: state.opportunityId,
2251
+ newStatus: state.newStatus,
2252
+ });
2253
+ try {
2254
+ if (!state.opportunityId) {
2255
+ return { mutationResult: { success: false, error: 'opportunityId is required.' } };
2256
+ }
2257
+ if (!state.newStatus || !['accepted', 'rejected', 'expired'].includes(state.newStatus)) {
2258
+ return { mutationResult: { success: false, error: 'newStatus must be one of: accepted, rejected, expired.' } };
2259
+ }
2260
+ const opp = await this.database.getOpportunity(state.opportunityId);
2261
+ if (!opp) {
2262
+ return { mutationResult: { success: false, error: 'Opportunity not found.' } };
2263
+ }
2264
+ const isActor = opp.actors.some((a) => a.userId === state.userId);
2265
+ if (!isActor) {
2266
+ return { mutationResult: { success: false, error: 'You are not part of this opportunity.' } };
2267
+ }
2268
+ await this.database.updateOpportunityStatus(state.opportunityId, state.newStatus);
2269
+ return {
2270
+ mutationResult: {
2271
+ success: true,
2272
+ opportunityId: state.opportunityId,
2273
+ message: `Opportunity status updated to ${state.newStatus}.`,
2274
+ },
2275
+ };
2276
+ }
2277
+ catch (err) {
2278
+ logger.error('[Graph:Update] Failed', { error: err });
2279
+ return { mutationResult: { success: false, error: 'Failed to update opportunity.' } };
2280
+ }
2281
+ });
2282
+ };
2283
+ /**
2284
+ * Delete Node: Expire/archive an opportunity.
2285
+ */
2286
+ const deleteNode = async (state) => {
2287
+ return timed("OpportunityGraph.delete", async () => {
2288
+ logger.verbose('[Graph:Delete] Expiring opportunity', {
2289
+ userId: state.userId,
2290
+ opportunityId: state.opportunityId,
2291
+ });
2292
+ try {
2293
+ if (!state.opportunityId) {
2294
+ return { mutationResult: { success: false, error: 'opportunityId is required.' } };
2295
+ }
2296
+ const opp = await this.database.getOpportunity(state.opportunityId);
2297
+ if (!opp) {
2298
+ return { mutationResult: { success: false, error: 'Opportunity not found.' } };
2299
+ }
2300
+ const isActor = opp.actors.some((a) => a.userId === state.userId);
2301
+ if (!isActor) {
2302
+ return { mutationResult: { success: false, error: 'You are not part of this opportunity.' } };
2303
+ }
2304
+ await this.database.updateOpportunityStatus(state.opportunityId, 'expired');
2305
+ return {
2306
+ mutationResult: {
2307
+ success: true,
2308
+ opportunityId: state.opportunityId,
2309
+ message: 'Opportunity archived (expired).',
2310
+ },
2311
+ };
2312
+ }
2313
+ catch (err) {
2314
+ logger.error('[Graph:Delete] Failed', { error: err });
2315
+ return { mutationResult: { success: false, error: 'Failed to delete opportunity.' } };
2316
+ }
2317
+ });
2318
+ };
2319
+ /**
2320
+ * Send Node: Promote latent or draft opportunity to pending + queue notification.
2321
+ */
2322
+ const sendNode = async (state) => {
2323
+ return timed("OpportunityGraph.send", async () => {
2324
+ logger.verbose('[Graph:Send] Sending opportunity', {
2325
+ userId: state.userId,
2326
+ opportunityId: state.opportunityId,
2327
+ });
2328
+ try {
2329
+ if (!state.opportunityId) {
2330
+ return { mutationResult: { success: false, error: 'opportunityId is required.' } };
2331
+ }
2332
+ const opp = await this.database.getOpportunity(state.opportunityId);
2333
+ if (!opp) {
2334
+ return { mutationResult: { success: false, error: 'Opportunity not found.' } };
2335
+ }
2336
+ const canSendStatus = opp.status === 'latent' || opp.status === 'draft';
2337
+ if (!canSendStatus) {
2338
+ return {
2339
+ mutationResult: {
2340
+ success: false,
2341
+ error: `Opportunity is already ${opp.status}; only latent or draft opportunities can be sent.`,
2342
+ },
2343
+ };
2344
+ }
2345
+ const senderActor = opp.actors.find((a) => a.userId === state.userId);
2346
+ const hasIntroducer = opp.actors.some((a) => a.role === 'introducer');
2347
+ const canSend = senderActor?.role === 'introducer' ||
2348
+ senderActor?.role === 'peer' ||
2349
+ (senderActor?.role === 'patient' && !hasIntroducer) ||
2350
+ (senderActor?.role === 'party' && !hasIntroducer);
2351
+ if (!senderActor) {
2352
+ return { mutationResult: { success: false, error: 'You are not part of this opportunity.' } };
2353
+ }
2354
+ if (!canSend) {
2355
+ return { mutationResult: { success: false, error: 'You cannot send this opportunity.' } };
2356
+ }
2357
+ await this.database.updateOpportunityStatus(state.opportunityId, 'pending');
2358
+ // Notify only the role that becomes visible at the next tier
2359
+ let recipients;
2360
+ if (senderActor.role === 'introducer') {
2361
+ recipients = opp.actors.filter((a) => a.role === 'patient' || a.role === 'party');
2362
+ }
2363
+ else if (senderActor.role === 'peer') {
2364
+ recipients = opp.actors.filter((a) => a.role === 'peer' && a.userId !== state.userId);
2365
+ }
2366
+ else {
2367
+ recipients = opp.actors.filter((a) => a.role === 'agent');
2368
+ }
2369
+ // queueNotification is injected via constructor; if not provided, notifications are skipped.
2370
+ const notifier = this.queueNotification;
2371
+ if (notifier) {
2372
+ for (const recipient of recipients) {
2373
+ await notifier(opp.id, recipient.userId, 'high');
2374
+ }
2375
+ }
2376
+ const recipientIds = recipients.map((a) => a.userId);
2377
+ return {
2378
+ mutationResult: {
2379
+ success: true,
2380
+ opportunityId: opp.id,
2381
+ notified: recipientIds,
2382
+ message: 'Opportunity sent. The other person has been notified.',
2383
+ },
2384
+ };
2385
+ }
2386
+ catch (err) {
2387
+ logger.error('[Graph:Send] Failed', { error: err });
2388
+ return { mutationResult: { success: false, error: 'Failed to send opportunity.' } };
2389
+ }
2390
+ });
2391
+ };
2392
+ // ═══════════════════════════════════════════════════════════════
2393
+ // CONDITIONAL ROUTING FUNCTIONS
2394
+ // ═══════════════════════════════════════════════════════════════
2395
+ /**
2396
+ * Router: Decides which path based on operationMode.
2397
+ */
2398
+ const routeByMode = (state) => {
2399
+ const mode = state.operationMode ?? 'create';
2400
+ if (mode === 'read')
2401
+ return 'read';
2402
+ if (mode === 'update')
2403
+ return 'update';
2404
+ if (mode === 'delete')
2405
+ return 'delete_opp';
2406
+ if (mode === 'send')
2407
+ return 'send';
2408
+ if (mode === 'create_introduction')
2409
+ return 'intro_validation';
2410
+ // 'create' is the default discovery pipeline
2411
+ return 'prep';
2412
+ };
2413
+ /**
2414
+ * After prep: check if user has indexed intents.
2415
+ * Early exit if none (cannot find opportunities).
2416
+ */
2417
+ const shouldContinueAfterPrep = (state) => {
2418
+ if (state.error) {
2419
+ logger.verbose('[Graph:Routing] Error in prep - ending early');
2420
+ return END;
2421
+ }
2422
+ // Continuation mode: skip scope/resolve/discovery, go straight to evaluation
2423
+ if (state.operationMode === 'continue_discovery') {
2424
+ logger.verbose('[Graph:Routing] Continue discovery → skipping to evaluation', {
2425
+ candidatesLoaded: state.candidates.length,
2426
+ });
2427
+ return 'evaluation';
2428
+ }
2429
+ logger.verbose('[Graph:Routing] Continuing to scope');
2430
+ return 'scope';
2431
+ };
2432
+ /**
2433
+ * After scope: check if we have target indexes.
2434
+ */
2435
+ const shouldContinueAfterScope = (state) => {
2436
+ if (state.error || state.targetIndexes.length === 0) {
2437
+ logger.verbose('[Graph:Routing] No target indexes - ending early');
2438
+ return END;
2439
+ }
2440
+ logger.verbose('[Graph:Routing] Continuing to resolve');
2441
+ return 'resolve';
2442
+ };
2443
+ /**
2444
+ * After discovery: if create-intent signal was set, end so tool can return it; else continue to evaluation.
2445
+ */
2446
+ const shouldContinueAfterDiscovery = (state) => {
2447
+ if (state.createIntentSuggested) {
2448
+ logger.verbose('[Graph:Routing] Create-intent suggested - ending for tool signal');
2449
+ return END;
2450
+ }
2451
+ return 'evaluation';
2452
+ };
2453
+ /**
2454
+ * After intro_validation: if validation set state.error, end early; else continue to intro_evaluation.
2455
+ */
2456
+ const routeAfterIntroValidation = (state) => {
2457
+ if (state.error) {
2458
+ logger.verbose('[Graph:Routing] Intro validation error - ending early');
2459
+ return END;
2460
+ }
2461
+ return 'intro_evaluation';
2462
+ };
2463
+ // ═══════════════════════════════════════════════════════════════
2464
+ // GRAPH ASSEMBLY
2465
+ // ═══════════════════════════════════════════════════════════════
2466
+ const workflow = new StateGraph(OpportunityGraphState)
2467
+ // Add all nodes
2468
+ .addNode('prep', prepNode)
2469
+ .addNode('scope', scopeNode)
2470
+ .addNode('resolve', resolveNode)
2471
+ .addNode('discovery', discoveryNode)
2472
+ .addNode('evaluation', evaluationNode)
2473
+ .addNode('ranking', rankingNode)
2474
+ .addNode('intro_validation', introValidationNode)
2475
+ .addNode('intro_evaluation', introEvaluationNode)
2476
+ .addNode('persist', persistNode)
2477
+ // CRUD nodes
2478
+ .addNode('read', readNode)
2479
+ .addNode('update', updateNode)
2480
+ .addNode('delete_opp', deleteNode)
2481
+ .addNode('send', sendNode)
2482
+ // Route by operation mode from START
2483
+ .addConditionalEdges(START, routeByMode, {
2484
+ prep: 'prep',
2485
+ intro_validation: 'intro_validation',
2486
+ read: 'read',
2487
+ update: 'update',
2488
+ delete_opp: 'delete_opp',
2489
+ send: 'send',
2490
+ })
2491
+ // Introduction path: validation -> evaluation -> persist (or END on validation error)
2492
+ .addConditionalEdges('intro_validation', routeAfterIntroValidation, {
2493
+ intro_evaluation: 'intro_evaluation',
2494
+ [END]: END,
2495
+ })
2496
+ .addEdge('intro_evaluation', 'persist')
2497
+ // CRUD fast paths -> END
2498
+ .addEdge('read', END)
2499
+ .addEdge('update', END)
2500
+ .addEdge('delete_opp', END)
2501
+ .addEdge('send', END)
2502
+ // Conditional routing: early exit if no indexed intents
2503
+ .addConditionalEdges('prep', shouldContinueAfterPrep, {
2504
+ scope: 'scope',
2505
+ evaluation: 'evaluation',
2506
+ [END]: END,
2507
+ })
2508
+ // Conditional routing: early exit if no target indexes
2509
+ .addConditionalEdges('scope', shouldContinueAfterScope, {
2510
+ resolve: 'resolve',
2511
+ [END]: END,
2512
+ })
2513
+ .addEdge('resolve', 'discovery')
2514
+ .addConditionalEdges('discovery', shouldContinueAfterDiscovery, {
2515
+ evaluation: 'evaluation',
2516
+ [END]: END,
2517
+ })
2518
+ // Negotiation step (optional, skipped for continue_discovery or when no negotiation graph)
2519
+ .addNode('negotiate', negotiateNode)
2520
+ .addConditionalEdges('evaluation', (state) => {
2521
+ if (state.operationMode === 'continue_discovery')
2522
+ return 'ranking';
2523
+ return 'negotiate';
2524
+ }, {
2525
+ negotiate: 'negotiate',
2526
+ ranking: 'ranking',
2527
+ })
2528
+ .addEdge('negotiate', 'ranking')
2529
+ .addEdge('ranking', 'persist')
2530
+ .addEdge('persist', END);
2531
+ return workflow.compile();
2532
+ }
2533
+ }
2534
+ //# sourceMappingURL=opportunity.graph.js.map