@indexnetwork/protocol 14.3.2-rc.477.1 → 17.0.0-rc.479.1

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 (184) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/IMPLEMENTATION.md +50 -52
  3. package/STABILITY.md +3 -3
  4. package/dist/chat/chat.agent.js +2 -3
  5. package/dist/chat/chat.graph.d.ts +41 -1
  6. package/dist/chat/chat.graph.js +108 -127
  7. package/dist/chat/negotiator.persona.d.ts +1 -1
  8. package/dist/chat/negotiator.persona.js +1 -4
  9. package/dist/chat/negotiator.prompt.js +1 -2
  10. package/dist/chat/onboarding.persona.js +0 -1
  11. package/dist/chat/reporter.persona.js +0 -1
  12. package/dist/chat/signal.persona.js +0 -1
  13. package/dist/contacts/application/contact.tools.d.ts +6 -9
  14. package/dist/contacts/application/contact.tools.js +11 -92
  15. package/dist/contacts/application/index.d.ts +2 -14
  16. package/dist/contacts/application/index.js +2 -13
  17. package/dist/contacts/domain/contact.types.d.ts +0 -23
  18. package/dist/contacts/domain/index.d.ts +2 -6
  19. package/dist/contacts/index.d.ts +1 -1
  20. package/dist/contacts/index.js +1 -1
  21. package/dist/contacts/ports/contact.repository.port.d.ts +5 -12
  22. package/dist/contacts/ports/contact.repository.port.js +4 -6
  23. package/dist/contacts/ports/contact.tools.port.d.ts +1 -3
  24. package/dist/contacts/ports/contact.tools.port.js +1 -2
  25. package/dist/contacts/ports/index.d.ts +1 -1
  26. package/dist/contacts/ports/index.js +1 -1
  27. package/dist/discovery/hyde.frame.d.ts +4 -4
  28. package/dist/discovery/hyde.graph.d.ts +71 -12
  29. package/dist/discovery/hyde.graph.js +337 -335
  30. package/dist/discovery/lens.inferrer.d.ts +6 -6
  31. package/dist/enrichment/enrichment.graph.d.ts +127 -9
  32. package/dist/enrichment/enrichment.graph.js +633 -655
  33. package/dist/enrichment/enrichment.state.d.ts +1 -1
  34. package/dist/enrichment/enrichment.tools.context-read.d.ts +10 -0
  35. package/dist/enrichment/enrichment.tools.context-read.js +353 -0
  36. package/dist/enrichment/enrichment.tools.context-write.d.ts +9 -0
  37. package/dist/enrichment/enrichment.tools.context-write.js +417 -0
  38. package/dist/enrichment/enrichment.tools.d.ts +11 -1
  39. package/dist/enrichment/enrichment.tools.helpers.d.ts +139 -0
  40. package/dist/enrichment/enrichment.tools.helpers.js +234 -0
  41. package/dist/enrichment/enrichment.tools.js +16 -975
  42. package/dist/index.d.ts +27 -50
  43. package/dist/index.js +17 -28
  44. package/dist/intents/application/intent.graph.d.ts +71 -67
  45. package/dist/intents/application/intent.graph.execute.d.ts +59 -0
  46. package/dist/intents/application/intent.graph.execute.js +301 -0
  47. package/dist/intents/application/intent.graph.infer.d.ts +44 -0
  48. package/dist/intents/application/intent.graph.infer.js +97 -0
  49. package/dist/intents/application/intent.graph.js +96 -888
  50. package/dist/intents/application/intent.graph.reconcile.d.ts +71 -0
  51. package/dist/intents/application/intent.graph.reconcile.js +274 -0
  52. package/dist/intents/application/intent.graph.shared.d.ts +70 -0
  53. package/dist/intents/application/intent.graph.shared.js +153 -0
  54. package/dist/intents/domain/intent.state.d.ts +1 -1
  55. package/dist/maintenance/maintenance.graph.d.ts +66 -3
  56. package/dist/maintenance/maintenance.graph.js +155 -156
  57. package/dist/mcp/mcp.authorization-policy.d.ts +24 -28
  58. package/dist/mcp/mcp.authorization-policy.js +14 -34
  59. package/dist/mcp/mcp.server.d.ts +3 -3
  60. package/dist/mcp/mcp.server.js +14 -20
  61. package/dist/negotiations/application/negotiation.candidates.d.ts +83 -0
  62. package/dist/negotiations/application/negotiation.candidates.js +162 -0
  63. package/dist/negotiations/application/negotiation.graph.d.ts +91 -157
  64. package/dist/negotiations/application/negotiation.graph.finalize.d.ts +5 -0
  65. package/dist/negotiations/application/negotiation.graph.finalize.js +280 -0
  66. package/dist/negotiations/application/negotiation.graph.init.d.ts +54 -0
  67. package/dist/negotiations/application/negotiation.graph.init.js +227 -0
  68. package/dist/negotiations/application/negotiation.graph.js +69 -1388
  69. package/dist/negotiations/application/negotiation.graph.screen.d.ts +23 -0
  70. package/dist/negotiations/application/negotiation.graph.screen.js +108 -0
  71. package/dist/negotiations/application/negotiation.graph.shared.d.ts +75 -0
  72. package/dist/negotiations/application/negotiation.graph.shared.js +125 -0
  73. package/dist/negotiations/application/negotiation.graph.turn.d.ts +105 -0
  74. package/dist/negotiations/application/negotiation.graph.turn.js +484 -0
  75. package/dist/negotiations/domain/negotiation.state.d.ts +1 -1
  76. package/dist/negotiations/domain/negotiation.state.js +0 -1
  77. package/dist/networks/application/indexer.graph.d.ts +165 -7
  78. package/dist/networks/application/indexer.graph.js +339 -388
  79. package/dist/networks/application/indexer.state.d.ts +1 -1
  80. package/dist/networks/application/membership.graph.d.ts +83 -5
  81. package/dist/networks/application/membership.graph.js +177 -207
  82. package/dist/networks/application/network.graph.d.ts +148 -5
  83. package/dist/networks/application/network.graph.js +249 -278
  84. package/dist/networks/domain/membership.state.d.ts +1 -1
  85. package/dist/networks/domain/network.state.d.ts +1 -1
  86. package/dist/opportunities/application/delivery-card.cache.d.ts +1 -1
  87. package/dist/opportunities/application/delivery-card.cache.js +2 -2
  88. package/dist/opportunities/application/index.d.ts +2 -2
  89. package/dist/opportunities/application/index.js +2 -2
  90. package/dist/opportunities/application/opportunity.evaluator.js +1 -1
  91. package/dist/opportunities/application/opportunity.graph.d.ts +686 -473
  92. package/dist/opportunities/application/opportunity.graph.discovery-strategies.d.ts +109 -0
  93. package/dist/opportunities/application/opportunity.graph.discovery-strategies.js +451 -0
  94. package/dist/opportunities/application/opportunity.graph.discovery.d.ts +69 -0
  95. package/dist/opportunities/application/opportunity.graph.discovery.js +397 -0
  96. package/dist/opportunities/application/opportunity.graph.evaluation.d.ts +82 -0
  97. package/dist/opportunities/application/opportunity.graph.evaluation.js +608 -0
  98. package/dist/opportunities/application/opportunity.graph.js +120 -3690
  99. package/dist/opportunities/application/opportunity.graph.modes.d.ts +538 -0
  100. package/dist/opportunities/application/opportunity.graph.modes.js +538 -0
  101. package/dist/opportunities/application/opportunity.graph.negotiate.d.ts +109 -0
  102. package/dist/opportunities/application/opportunity.graph.negotiate.js +392 -0
  103. package/dist/opportunities/application/opportunity.graph.persist-node.d.ts +93 -0
  104. package/dist/opportunities/application/opportunity.graph.persist-node.js +765 -0
  105. package/dist/opportunities/application/opportunity.graph.prep.d.ts +150 -0
  106. package/dist/opportunities/application/opportunity.graph.prep.js +381 -0
  107. package/dist/opportunities/application/opportunity.graph.shared.d.ts +173 -0
  108. package/dist/opportunities/application/opportunity.graph.shared.js +199 -0
  109. package/dist/opportunities/application/opportunity.presentation.d.ts +399 -0
  110. package/dist/opportunities/application/{opportunity.presenter.js → opportunity.presentation.js} +735 -9
  111. package/dist/opportunities/application/opportunity.tools.cards.d.ts +152 -0
  112. package/dist/opportunities/application/opportunity.tools.cards.js +235 -0
  113. package/dist/opportunities/application/opportunity.tools.d.ts +8 -114
  114. package/dist/opportunities/application/opportunity.tools.js +28 -711
  115. package/dist/opportunities/application/opportunity.tools.list.d.ts +10 -0
  116. package/dist/opportunities/application/opportunity.tools.list.js +493 -0
  117. package/dist/opportunities/domain/index.d.ts +3 -3
  118. package/dist/opportunities/domain/index.js +3 -3
  119. package/dist/opportunities/domain/opportunity.state.d.ts +19 -19
  120. package/dist/opportunities/index.d.ts +6 -6
  121. package/dist/opportunities/index.js +4 -4
  122. package/dist/opportunities/ports/opportunity.tools.port.d.ts +1 -1
  123. package/dist/opportunities/radar/radar.graph.d.ts +57 -13
  124. package/dist/opportunities/radar/radar.graph.js +470 -471
  125. package/dist/premises/premise.graph.d.ts +88 -20
  126. package/dist/premises/premise.graph.js +207 -218
  127. package/dist/questions/domain/question.schema.d.ts +40 -40
  128. package/dist/shared/agent/tool.factory.js +0 -8
  129. package/dist/shared/agent/tool.helpers.d.ts +21 -45
  130. package/dist/shared/agent/tool.helpers.js +1 -2
  131. package/dist/shared/agent/tool.registry.d.ts +4 -3
  132. package/dist/shared/agent/tool.registry.js +2 -4
  133. package/dist/shared/agent/tool.runtime.js +0 -2
  134. package/dist/shared/agent/utility.tools.js +9 -13
  135. package/dist/shared/interfaces/database.capabilities.d.ts +100 -0
  136. package/dist/shared/interfaces/database.capabilities.js +7 -0
  137. package/dist/shared/interfaces/database.entities.d.ts +533 -0
  138. package/dist/shared/interfaces/database.entities.js +10 -0
  139. package/dist/shared/interfaces/database.identity-queries.d.ts +294 -0
  140. package/dist/shared/interfaces/database.identity-queries.js +4 -0
  141. package/dist/shared/interfaces/database.interface.d.ts +15 -2277
  142. package/dist/shared/interfaces/database.interface.js +8 -0
  143. package/dist/shared/interfaces/database.member-queries.d.ts +221 -0
  144. package/dist/shared/interfaces/database.member-queries.js +4 -0
  145. package/dist/shared/interfaces/database.negotiation.d.ts +305 -0
  146. package/dist/shared/interfaces/database.negotiation.js +26 -0
  147. package/dist/shared/interfaces/database.network-queries.d.ts +277 -0
  148. package/dist/shared/interfaces/database.network-queries.js +4 -0
  149. package/dist/shared/interfaces/database.opportunity-queries.d.ts +285 -0
  150. package/dist/shared/interfaces/database.opportunity-queries.js +4 -0
  151. package/dist/shared/interfaces/database.port.d.ts +322 -0
  152. package/dist/shared/interfaces/database.port.js +29 -0
  153. package/dist/shared/schemas/discovery-question.schema.d.ts +14 -14
  154. package/dist/shared/schemas/negotiation-digest.schema.d.ts +2 -2
  155. package/package.json +2 -15
  156. package/dist/contacts/application/contact.inviter.d.ts +0 -49
  157. package/dist/contacts/application/contact.inviter.js +0 -64
  158. package/dist/integrations/application/index.d.ts +0 -14
  159. package/dist/integrations/application/index.js +0 -14
  160. package/dist/integrations/application/integration.tools.d.ts +0 -27
  161. package/dist/integrations/application/integration.tools.js +0 -98
  162. package/dist/integrations/domain/index.d.ts +0 -16
  163. package/dist/integrations/domain/index.js +0 -1
  164. package/dist/integrations/domain/integration.types.d.ts +0 -51
  165. package/dist/integrations/domain/integration.types.js +0 -9
  166. package/dist/integrations/index.d.ts +0 -10
  167. package/dist/integrations/index.js +0 -8
  168. package/dist/integrations/ports/index.d.ts +0 -25
  169. package/dist/integrations/ports/index.js +0 -23
  170. package/dist/integrations/ports/integration.adapter.port.d.ts +0 -62
  171. package/dist/integrations/ports/integration.adapter.port.js +0 -15
  172. package/dist/integrations/ports/integration.importer.port.d.ts +0 -31
  173. package/dist/integrations/ports/integration.importer.port.js +0 -15
  174. package/dist/integrations/ports/integration.tools.port.d.ts +0 -25
  175. package/dist/integrations/ports/integration.tools.port.js +0 -18
  176. package/dist/opportunities/application/opportunity.card-presentation.d.ts +0 -44
  177. package/dist/opportunities/application/opportunity.card-presentation.js +0 -93
  178. package/dist/opportunities/application/opportunity.presenter.d.ts +0 -156
  179. package/dist/opportunities/domain/opportunity.presentation-cache.d.ts +0 -5
  180. package/dist/opportunities/domain/opportunity.presentation-cache.js +0 -11
  181. package/dist/opportunities/domain/opportunity.presentation.d.ts +0 -76
  182. package/dist/opportunities/domain/opportunity.presentation.js +0 -516
  183. package/dist/opportunities/domain/opportunity.safe-presentation.d.ts +0 -104
  184. package/dist/opportunities/domain/opportunity.safe-presentation.js +0 -102
@@ -12,12 +12,12 @@
12
12
  */
13
13
  import { StateGraph, START, END } from '@langchain/langgraph';
14
14
  import { RadarGraphState } from './radar.state.js';
15
- import { OpportunityPresenter, gatherPresenterContext } from '../application/opportunity.presenter.js';
15
+ import { OpportunityPresenter, gatherPresenterContext } from '../application/opportunity.presentation.js';
16
16
  import { loadNegotiationContext } from '../application/negotiation-context.loader.js';
17
17
  import { canUserSeeOpportunity, isActionableForViewer, selectByComposition } from '../domain/opportunity.utils.js';
18
18
  import { getPrimaryActionLabel, SECONDARY_ACTION_LABEL } from '../domain/opportunity.labels.js';
19
- import { safeFallbackSummary } from '../domain/opportunity.safe-presentation.js';
20
- import { buildRadarCardPresentationCacheKey } from '../domain/opportunity.presentation-cache.js';
19
+ import { safeFallbackSummary } from '../application/opportunity.presentation.js';
20
+ import { buildRadarCardPresentationCacheKey } from '../application/opportunity.presentation.js';
21
21
  import { protocolLogger } from '../../shared/observability/protocol.logger.js';
22
22
  import { timed } from '../../shared/observability/performance.js';
23
23
  import { requestContext } from "../../shared/observability/request-context.js";
@@ -170,493 +170,492 @@ const pickDisplayCounterpartActor = (opportunity, viewerId) => {
170
170
  };
171
171
  export class RadarGraphFactory {
172
172
  constructor(database, cache) {
173
- this.database = database;
174
- this.cache = cache;
173
+ this.deps = { database, cache, presenter: new OpportunityPresenter() };
175
174
  }
176
175
  createGraph() {
177
- const presenter = new OpportunityPresenter();
178
- const loadOpportunitiesNode = async (state) => {
179
- return timed("RadarGraph.loadOpportunities", async () => {
180
- if (!state.userId) {
181
- return { error: 'userId is required' };
182
- }
183
- try {
184
- // Minimum of 50 ensures enough candidates across all radar categories
185
- // (connection, connector-flow, expired) for selectByComposition to fill
186
- // its soft targets, even after visibility filtering and dedup.
187
- const fetchLimit = Math.min(150, Math.max(50, state.limit * 3));
188
- const statuses = state.statuses ?? DEFAULT_RADAR_STATUSES;
189
- const poolRankingProvenance = getPoolRankingProvenance(state);
190
- const options = {
191
- limit: fetchLimit,
192
- statuses,
193
- };
194
- if (state.networkId)
195
- options.networkId = state.networkId;
196
- if (state.scopeType === 'intent' && state.scopeId) {
197
- options.scopeType = 'intent';
198
- options.scopeId = state.scopeId;
199
- }
200
- // Do not pass conversationId: radar view excludes draft opportunities (chat-only drafts).
201
- const raw = await this.database.getOpportunitiesForUser(state.userId, options);
202
- const visible = raw.filter((opp) => canUserSeeOpportunity(opp.actors, opp.status, state.userId));
203
- // Actionability only gates the live statuses a viewer could act on:
204
- // latent/pending cards the viewer cannot act on are noise, but
205
- // terminal/internal statuses (accepted, rejected, expired, negotiating,
206
- // stalled, draft) are deliberate history — when a caller explicitly
207
- // requests them via `statuses`, they must pass through (they are never
208
- // actionable by rule 5, so filtering them here would return nothing).
209
- // The requested-status membership check is defense-in-depth: rows
210
- // outside the requested set are dropped even if the adapter drifts.
211
- const requestedStatuses = new Set(statuses);
212
- const visibleForRadar = visible.filter((opp) => {
213
- if (!requestedStatuses.has(opp.status))
214
- return false;
215
- if (opp.status === 'latent' || opp.status === 'pending') {
216
- return isActionableForViewer(opp.actors, opp.status, state.userId);
217
- }
218
- return true;
219
- });
220
- const explicitStatuses = (state.statuses?.length ?? 0) > 0;
221
- if (explicitStatuses) {
222
- // Lifecycle view (e.g. intent radar): newest-first so counterpart
223
- // dedup keeps each person's most recent state (an accepted
224
- // opportunity supersedes an older pending one), no composition
225
- // capping the caller wants the full pipeline up to `limit`.
226
- const newestFirst = [...visibleForRadar].sort((a, b) => safeParseDate(b.updatedAt) - safeParseDate(a.updatedAt));
227
- const seenIds = new Set();
228
- const dedupedByCounterpart = newestFirst.filter((opp) => {
229
- const counterpartIds = getUniqueCounterpartUserIds(opp, state.userId);
230
- const hasOverlap = [...counterpartIds].some((id) => seenIds.has(id));
231
- if (hasOverlap)
232
- return false;
233
- for (const id of counterpartIds)
234
- seenIds.add(id);
235
- return true;
236
- });
237
- // Preserve byte-identical newest-first behavior while the flag is
238
- // off. When on, re-order the already-deduped lifecycle set by
239
- // adjusted confidence so each client-side radar bucket reflects
240
- // pool answers immediately.
241
- if (!poolRankingProvenance ||
242
- !dedupedByCounterpart.some((opportunity) => hasPoolAdjustment(opportunity, poolRankingProvenance))) {
243
- return { opportunities: dedupedByCounterpart.slice(0, state.limit) };
244
- }
245
- const adjustedOrder = [...dedupedByCounterpart].sort((a, b) => {
246
- const confidenceDelta = getConfidence(b, poolRankingProvenance) - getConfidence(a, poolRankingProvenance);
247
- if (confidenceDelta !== 0)
248
- return confidenceDelta;
249
- return safeParseDate(b.updatedAt) - safeParseDate(a.updatedAt);
250
- });
251
- return { opportunities: adjustedOrder.slice(0, state.limit) };
252
- }
253
- const sorted = [...visibleForRadar].sort((a, b) => {
254
- // Connections before connector-flow so dedup claims counterpart IDs
255
- // for direct connections first — prevents introducer cards from
256
- // shadowing a user's own connection opportunities.
257
- const aIsIntroducer = a.actors.some((ac) => ac.userId === state.userId && ac.role === 'introducer');
258
- const bIsIntroducer = b.actors.some((ac) => ac.userId === state.userId && ac.role === 'introducer');
259
- if (aIsIntroducer !== bIsIntroducer)
260
- return aIsIntroducer ? 1 : -1;
261
- const confA = getConfidence(a, poolRankingProvenance);
262
- const confB = getConfidence(b, poolRankingProvenance);
263
- if (confB !== confA)
264
- return confB - confA;
265
- const aTime = safeParseDate(a.updatedAt);
266
- const bTime = safeParseDate(b.updatedAt);
267
- return bTime - aTime;
268
- });
269
- const seenUserIds = new Set();
270
- const deduped = sorted.filter((opp) => {
271
- const counterpartIds = getUniqueCounterpartUserIds(opp, state.userId);
272
- const hasOverlap = [...counterpartIds].some((id) => seenUserIds.has(id));
273
- if (hasOverlap)
274
- return false;
275
- for (const id of counterpartIds)
276
- seenUserIds.add(id);
277
- return true;
278
- });
279
- const opportunities = selectByComposition(deduped, state.userId);
280
- return { opportunities };
281
- }
282
- catch (e) {
283
- logger.error('RadarGraph loadOpportunities failed', { error: e });
284
- return { error: 'Failed to load opportunities', opportunities: [] };
176
+ const deps = this.deps;
177
+ const graph = new StateGraph(RadarGraphState)
178
+ .addNode('loadOpportunities', (state) => loadOpportunitiesNode(state, deps))
179
+ .addNode('checkPresenterCache', (state) => checkPresenterCacheNode(state, deps))
180
+ .addNode('generateCardText', (state) => generateCardTextNode(state, deps))
181
+ .addNode('cachePresenterResults', (state) => cachePresenterResultsNode(state, deps))
182
+ .addNode('normalizeItems', normalizeItemsNode)
183
+ .addEdge(START, 'loadOpportunities')
184
+ .addEdge('loadOpportunities', 'checkPresenterCache')
185
+ .addConditionalEdges('checkPresenterCache', (state) => shouldGenerateCards(state, deps), {
186
+ generate: 'generateCardText',
187
+ skip: 'cachePresenterResults',
188
+ })
189
+ .addEdge('generateCardText', 'cachePresenterResults')
190
+ .addEdge('cachePresenterResults', 'normalizeItems')
191
+ .addEdge('normalizeItems', END);
192
+ return graph.compile();
193
+ }
194
+ }
195
+ export async function loadOpportunitiesNode(state, deps) {
196
+ return timed("RadarGraph.loadOpportunities", async () => {
197
+ if (!state.userId) {
198
+ return { error: 'userId is required' };
199
+ }
200
+ try {
201
+ // Minimum of 50 ensures enough candidates across all radar categories
202
+ // (connection, connector-flow, expired) for selectByComposition to fill
203
+ // its soft targets, even after visibility filtering and dedup.
204
+ const fetchLimit = Math.min(150, Math.max(50, state.limit * 3));
205
+ const statuses = state.statuses ?? DEFAULT_RADAR_STATUSES;
206
+ const poolRankingProvenance = getPoolRankingProvenance(state);
207
+ const options = {
208
+ limit: fetchLimit,
209
+ statuses,
210
+ };
211
+ if (state.networkId)
212
+ options.networkId = state.networkId;
213
+ if (state.scopeType === 'intent' && state.scopeId) {
214
+ options.scopeType = 'intent';
215
+ options.scopeId = state.scopeId;
216
+ }
217
+ // Do not pass conversationId: radar view excludes draft opportunities (chat-only drafts).
218
+ const raw = await deps.database.getOpportunitiesForUser(state.userId, options);
219
+ const visible = raw.filter((opp) => canUserSeeOpportunity(opp.actors, opp.status, state.userId));
220
+ // Actionability only gates the live statuses a viewer could act on:
221
+ // latent/pending cards the viewer cannot act on are noise, but
222
+ // terminal/internal statuses (accepted, rejected, expired, negotiating,
223
+ // stalled, draft) are deliberate history when a caller explicitly
224
+ // requests them via `statuses`, they must pass through (they are never
225
+ // actionable by rule 5, so filtering them here would return nothing).
226
+ // The requested-status membership check is defense-in-depth: rows
227
+ // outside the requested set are dropped even if the adapter drifts.
228
+ const requestedStatuses = new Set(statuses);
229
+ const visibleForRadar = visible.filter((opp) => {
230
+ if (!requestedStatuses.has(opp.status))
231
+ return false;
232
+ if (opp.status === 'latent' || opp.status === 'pending') {
233
+ return isActionableForViewer(opp.actors, opp.status, state.userId);
285
234
  }
235
+ return true;
286
236
  });
287
- };
288
- const checkPresenterCacheNode = async (state) => {
289
- return timed("RadarGraph.checkPresenterCache", async () => {
290
- const { opportunities, userId } = state;
291
- const poolRankingProvenance = getPoolRankingProvenance(state);
292
- if (opportunities.length === 0) {
293
- return { cachedCards: new Map(), uncachedOpportunities: [] };
294
- }
295
- if (state.noCache) {
296
- checkPresenterCacheLog.verbose('noCache=true, skipping cache');
297
- return { cachedCards: new Map(), uncachedOpportunities: opportunities };
237
+ const explicitStatuses = (state.statuses?.length ?? 0) > 0;
238
+ if (explicitStatuses) {
239
+ // Lifecycle view (e.g. intent radar): newest-first so counterpart
240
+ // dedup keeps each person's most recent state (an accepted
241
+ // opportunity supersedes an older pending one), no composition
242
+ // capping the caller wants the full pipeline up to `limit`.
243
+ const newestFirst = [...visibleForRadar].sort((a, b) => safeParseDate(b.updatedAt) - safeParseDate(a.updatedAt));
244
+ const seenIds = new Set();
245
+ const dedupedByCounterpart = newestFirst.filter((opp) => {
246
+ const counterpartIds = getUniqueCounterpartUserIds(opp, state.userId);
247
+ const hasOverlap = [...counterpartIds].some((id) => seenIds.has(id));
248
+ if (hasOverlap)
249
+ return false;
250
+ for (const id of counterpartIds)
251
+ seenIds.add(id);
252
+ return true;
253
+ });
254
+ // Preserve byte-identical newest-first behavior while the flag is
255
+ // off. When on, re-order the already-deduped lifecycle set by
256
+ // adjusted confidence so each client-side radar bucket reflects
257
+ // pool answers immediately.
258
+ if (!poolRankingProvenance ||
259
+ !dedupedByCounterpart.some((opportunity) => hasPoolAdjustment(opportunity, poolRankingProvenance))) {
260
+ return { opportunities: dedupedByCounterpart.slice(0, state.limit) };
298
261
  }
299
- try {
300
- // Negotiating cards are templated (no LLM call) and their text
301
- // depends on the live turn count, which changes between requests
302
- // without changing the opportunity status. Skip cache entirely
303
- // for them so each render reflects the current turn.
304
- //
305
- // For all other statuses, include status in the key so status
306
- // transitions (e.g. negotiating → pending) don't serve stale cards.
307
- const cacheable = opportunities.filter((opp) => opp.status !== 'negotiating');
308
- const liveNegotiating = opportunities.filter((opp) => opp.status === 'negotiating');
309
- const keys = cacheable.map((opp) => buildRadarCardPresentationCacheKey(opp.id, opp.status, userId));
310
- const results = keys.length > 0 ? await this.cache.mget(keys) : [];
311
- const cachedCards = new Map();
312
- const uncachedOpportunities = [...liveNegotiating];
313
- for (let i = 0; i < cacheable.length; i++) {
314
- const cached = results[i];
315
- if (cached) {
316
- const originalIndex = opportunities.indexOf(cacheable[i]);
317
- // Stamp the live status: pre-status cache entries lack the field,
318
- // and the key already guarantees it matches the current status.
319
- const deprioritizedReason = poolRankingProvenance
320
- ? latestPoolDemotionDetail(cacheable[i].metadata, poolRankingProvenance)
321
- : undefined;
322
- cachedCards.set(cacheable[i].id, {
323
- ...cached,
324
- status: cacheable[i].status,
325
- deprioritizedReason,
326
- _cardIndex: originalIndex,
327
- });
328
- }
329
- else {
330
- uncachedOpportunities.push(cacheable[i]);
331
- }
332
- }
333
- checkPresenterCacheLog.verbose('', {
334
- total: opportunities.length,
335
- cacheHits: cachedCards.size,
336
- cacheMisses: uncachedOpportunities.length,
262
+ const adjustedOrder = [...dedupedByCounterpart].sort((a, b) => {
263
+ const confidenceDelta = getConfidence(b, poolRankingProvenance) - getConfidence(a, poolRankingProvenance);
264
+ if (confidenceDelta !== 0)
265
+ return confidenceDelta;
266
+ return safeParseDate(b.updatedAt) - safeParseDate(a.updatedAt);
267
+ });
268
+ return { opportunities: adjustedOrder.slice(0, state.limit) };
269
+ }
270
+ const sorted = [...visibleForRadar].sort((a, b) => {
271
+ // Connections before connector-flow so dedup claims counterpart IDs
272
+ // for direct connections first prevents introducer cards from
273
+ // shadowing a user's own connection opportunities.
274
+ const aIsIntroducer = a.actors.some((ac) => ac.userId === state.userId && ac.role === 'introducer');
275
+ const bIsIntroducer = b.actors.some((ac) => ac.userId === state.userId && ac.role === 'introducer');
276
+ if (aIsIntroducer !== bIsIntroducer)
277
+ return aIsIntroducer ? 1 : -1;
278
+ const confA = getConfidence(a, poolRankingProvenance);
279
+ const confB = getConfidence(b, poolRankingProvenance);
280
+ if (confB !== confA)
281
+ return confB - confA;
282
+ const aTime = safeParseDate(a.updatedAt);
283
+ const bTime = safeParseDate(b.updatedAt);
284
+ return bTime - aTime;
285
+ });
286
+ const seenUserIds = new Set();
287
+ const deduped = sorted.filter((opp) => {
288
+ const counterpartIds = getUniqueCounterpartUserIds(opp, state.userId);
289
+ const hasOverlap = [...counterpartIds].some((id) => seenUserIds.has(id));
290
+ if (hasOverlap)
291
+ return false;
292
+ for (const id of counterpartIds)
293
+ seenUserIds.add(id);
294
+ return true;
295
+ });
296
+ const opportunities = selectByComposition(deduped, state.userId);
297
+ return { opportunities };
298
+ }
299
+ catch (e) {
300
+ logger.error('RadarGraph loadOpportunities failed', { error: e });
301
+ return { error: 'Failed to load opportunities', opportunities: [] };
302
+ }
303
+ });
304
+ }
305
+ export async function checkPresenterCacheNode(state, deps) {
306
+ return timed("RadarGraph.checkPresenterCache", async () => {
307
+ const { opportunities, userId } = state;
308
+ const poolRankingProvenance = getPoolRankingProvenance(state);
309
+ if (opportunities.length === 0) {
310
+ return { cachedCards: new Map(), uncachedOpportunities: [] };
311
+ }
312
+ if (state.noCache) {
313
+ checkPresenterCacheLog.verbose('noCache=true, skipping cache');
314
+ return { cachedCards: new Map(), uncachedOpportunities: opportunities };
315
+ }
316
+ try {
317
+ // Negotiating cards are templated (no LLM call) and their text
318
+ // depends on the live turn count, which changes between requests
319
+ // without changing the opportunity status. Skip cache entirely
320
+ // for them so each render reflects the current turn.
321
+ //
322
+ // For all other statuses, include status in the key so status
323
+ // transitions (e.g. negotiating → pending) don't serve stale cards.
324
+ const cacheable = opportunities.filter((opp) => opp.status !== 'negotiating');
325
+ const liveNegotiating = opportunities.filter((opp) => opp.status === 'negotiating');
326
+ const keys = cacheable.map((opp) => buildRadarCardPresentationCacheKey(opp.id, opp.status, userId));
327
+ const results = keys.length > 0 ? await deps.cache.mget(keys) : [];
328
+ const cachedCards = new Map();
329
+ const uncachedOpportunities = [...liveNegotiating];
330
+ for (let i = 0; i < cacheable.length; i++) {
331
+ const cached = results[i];
332
+ if (cached) {
333
+ const originalIndex = opportunities.indexOf(cacheable[i]);
334
+ // Stamp the live status: pre-status cache entries lack the field,
335
+ // and the key already guarantees it matches the current status.
336
+ const deprioritizedReason = poolRankingProvenance
337
+ ? latestPoolDemotionDetail(cacheable[i].metadata, poolRankingProvenance)
338
+ : undefined;
339
+ cachedCards.set(cacheable[i].id, {
340
+ ...cached,
341
+ status: cacheable[i].status,
342
+ deprioritizedReason,
343
+ _cardIndex: originalIndex,
337
344
  });
338
- return { cachedCards, uncachedOpportunities };
339
345
  }
340
- catch (e) {
341
- checkPresenterCacheLog.warn('cache unavailable, skipping', { error: e });
342
- return { cachedCards: new Map(), uncachedOpportunities: opportunities };
346
+ else {
347
+ uncachedOpportunities.push(cacheable[i]);
343
348
  }
349
+ }
350
+ checkPresenterCacheLog.verbose('', {
351
+ total: opportunities.length,
352
+ cacheHits: cachedCards.size,
353
+ cacheMisses: uncachedOpportunities.length,
344
354
  });
345
- };
346
- const shouldGenerateCards = (state) => {
347
- if (state.uncachedOpportunities.length > 0) {
348
- return 'generate';
355
+ return { cachedCards, uncachedOpportunities };
356
+ }
357
+ catch (e) {
358
+ checkPresenterCacheLog.warn('cache unavailable, skipping', { error: e });
359
+ return { cachedCards: new Map(), uncachedOpportunities: opportunities };
360
+ }
361
+ });
362
+ }
363
+ export function shouldGenerateCards(state, deps) {
364
+ if (state.uncachedOpportunities.length > 0) {
365
+ return 'generate';
366
+ }
367
+ logger.verbose('All presenter results cached, skipping generation');
368
+ return 'skip';
369
+ }
370
+ export async function generateCardTextNode(state, deps) {
371
+ return timed("RadarGraph.generateCardText", async () => {
372
+ const opportunities = state.uncachedOpportunities.length > 0
373
+ ? state.uncachedOpportunities
374
+ : state.opportunities;
375
+ generateCardTextLog.verbose('entry', { opportunitiesLength: opportunities.length, userId: state.userId });
376
+ if (opportunities.length === 0) {
377
+ generateCardTextLog.verbose('exit', { totalOpportunities: 0 });
378
+ return { cards: [], agentTimings: [], meta: { totalOpportunities: 0 } };
379
+ }
380
+ const db = deps.database;
381
+ const cards = [];
382
+ const relevantActorIds = new Set();
383
+ for (const opp of opportunities) {
384
+ for (const a of opp.actors) {
385
+ if (a.userId)
386
+ relevantActorIds.add(a.userId);
349
387
  }
350
- logger.verbose('All presenter results cached, skipping generation');
351
- return 'skip';
352
- };
353
- const generateCardTextNode = async (state) => {
354
- return timed("RadarGraph.generateCardText", async () => {
355
- const opportunities = state.uncachedOpportunities.length > 0
356
- ? state.uncachedOpportunities
357
- : state.opportunities;
358
- generateCardTextLog.verbose('entry', { opportunitiesLength: opportunities.length, userId: state.userId });
359
- if (opportunities.length === 0) {
360
- generateCardTextLog.verbose('exit', { totalOpportunities: 0 });
361
- return { cards: [], agentTimings: [], meta: { totalOpportunities: 0 } };
388
+ }
389
+ const userEntries = await Promise.all(Array.from(relevantActorIds).map(async (userId) => {
390
+ try {
391
+ const user = await deps.database.getUser(userId);
392
+ return [userId, user ?? null];
393
+ }
394
+ catch {
395
+ return [userId, null];
396
+ }
397
+ }));
398
+ const userMap = new Map(userEntries);
399
+ const oppIndexMap = new Map(state.opportunities.map((opp, idx) => [opp.id, idx]));
400
+ const agentTimingsAccum = [];
401
+ for (let i = 0; i < opportunities.length; i += PRESENTATION_CONCURRENCY) {
402
+ const chunk = opportunities.slice(i, i + PRESENTATION_CONCURRENCY);
403
+ const chunkCards = await Promise.all(chunk.map(async (opportunity, offset) => {
404
+ const cardIndex = oppIndexMap.get(opportunity.id) ?? (i + offset);
405
+ const viewerActor = opportunity.actors.find((a) => a.userId === state.userId);
406
+ const viewerRole = viewerActor?.role ?? 'party';
407
+ const isIntroducer = viewerRole === 'introducer';
408
+ const preferredActor = pickDisplayCounterpartActor(opportunity, state.userId)
409
+ ?? opportunity.actors.find((a) => a.userId !== state.userId && a.role !== 'introducer');
410
+ const actorWithProfile = opportunity.actors.find((a) => a.userId !== state.userId && a.role !== 'introducer' && !!userMap.get(a.userId));
411
+ const introducer = opportunity.actors.find((a) => a.role === 'introducer');
412
+ let otherActor = (preferredActor && userMap.get(preferredActor.userId))
413
+ ? preferredActor
414
+ : (actorWithProfile ?? preferredActor);
415
+ // When the only other participant is the introducer (no separate party), use introducer as display counterpart so the card shows a name instead of "Unknown"
416
+ if (!otherActor && introducer && introducer.userId !== state.userId && introducer.userId) {
417
+ otherActor = { userId: introducer.userId, role: introducer.role };
362
418
  }
363
- const db = this.database;
364
- const cards = [];
365
- const relevantActorIds = new Set();
366
- for (const opp of opportunities) {
367
- for (const a of opp.actors) {
368
- if (a.userId)
369
- relevantActorIds.add(a.userId);
370
- }
419
+ const otherUser = otherActor ? userMap.get(otherActor.userId) ?? null : null;
420
+ const introducerCounterparts = opportunity.actors.filter((a) => a.userId !== state.userId && a.role !== 'introducer');
421
+ // Deduplicate by userId — actors array can contain multiple rows per user
422
+ // (e.g. from different intents), which would produce repeated names.
423
+ const uniqueCounterpartIds = [...new Set(introducerCounterparts.map((a) => a.userId))];
424
+ const participantNames = uniqueCounterpartIds
425
+ .map((uid) => userMap.get(uid)?.name ?? 'Unknown')
426
+ .sort();
427
+ // When secondPartyData will be present (2+ counterparts), use single counterpart name
428
+ // because the frontend arrow layout renders "card.name → secondParty.name".
429
+ // Using the joined "A ↔ B" format here would produce redundant "A ↔ B → B".
430
+ // Only use the joined format when there is a single counterpart (no arrow layout).
431
+ const willHaveSecondParty = isIntroducer && uniqueCounterpartIds.length > 1;
432
+ let userName = isIntroducer && participantNames.length > 0 && !willHaveSecondParty
433
+ ? participantNames.join(' ↔ ')
434
+ : (otherUser?.name ?? 'Unknown');
435
+ // Fallback to profile identity name when users.name is missing (e.g. profile has display name, users row does not)
436
+ if ((userName === 'Unknown' || !userName?.trim()) && otherActor?.userId && db.getProfile) {
437
+ const profile = await db.getProfile(otherActor.userId).catch((err) => {
438
+ logger.debug('getProfile fallback failed', { otherActorUserId: otherActor.userId, error: err });
439
+ return null;
440
+ });
441
+ const profileName = profile?.identity?.name?.trim();
442
+ if (profileName)
443
+ userName = profileName;
371
444
  }
372
- const userEntries = await Promise.all(Array.from(relevantActorIds).map(async (userId) => {
373
- try {
374
- const user = await this.database.getUser(userId);
375
- return [userId, user ?? null];
376
- }
377
- catch {
378
- return [userId, null];
445
+ // Unresolvable display counterpart (deleted user: no users row, no
446
+ // profile fallback). Drop the card entirely instead of rendering an
447
+ // "Unknown" placeholder: such cards are unusable, excluded from the
448
+ // deps.presenter cache (see cachePresenterResults), and would otherwise
449
+ // trigger a fresh deps.presenter LLM call on every request — a permanent
450
+ // cache miss that keeps the whole radar slow (~9s per load).
451
+ if (userName === 'Unknown' || !userName?.trim()) {
452
+ logger.verbose('[RadarGraph:generateCardText] dropping card with unresolvable counterpart', {
453
+ opportunityId: opportunity.id,
454
+ otherActorUserId: otherActor?.userId,
455
+ });
456
+ return null;
457
+ }
458
+ const userAvatar = otherUser?.avatar ?? null;
459
+ // Shared sanitization standard (UUID strip, viewer-centric rewrite,
460
+ // boundary truncation) — raw reasoning must never render verbatim.
461
+ const reasoningSnippet = safeFallbackSummary(typeof opportunity.interpretation?.reasoning === 'string'
462
+ ? opportunity.interpretation.reasoning
463
+ : '', {
464
+ counterpartName: userName !== 'Unknown' ? userName : undefined,
465
+ maxChars: MAX_REASONING_SNIPPET_LENGTH,
466
+ emptyText: 'A promising connection.',
467
+ });
468
+ // Build secondParty for introducer arrow layout (the party that isn't the display counterpart)
469
+ let secondPartyData;
470
+ if (isIntroducer && introducerCounterparts.length > 1 && otherActor) {
471
+ const secondActor = introducerCounterparts.find((a) => a.userId !== otherActor.userId);
472
+ if (secondActor) {
473
+ const secondUser = userMap.get(secondActor.userId) ?? null;
474
+ secondPartyData = {
475
+ name: secondUser?.name ?? 'Unknown',
476
+ avatar: secondUser?.avatar ?? null,
477
+ userId: secondActor.userId,
478
+ };
379
479
  }
380
- }));
381
- const userMap = new Map(userEntries);
382
- const oppIndexMap = new Map(state.opportunities.map((opp, idx) => [opp.id, idx]));
383
- const agentTimingsAccum = [];
384
- for (let i = 0; i < opportunities.length; i += PRESENTATION_CONCURRENCY) {
385
- const chunk = opportunities.slice(i, i + PRESENTATION_CONCURRENCY);
386
- const chunkCards = await Promise.all(chunk.map(async (opportunity, offset) => {
387
- const cardIndex = oppIndexMap.get(opportunity.id) ?? (i + offset);
388
- const viewerActor = opportunity.actors.find((a) => a.userId === state.userId);
389
- const viewerRole = viewerActor?.role ?? 'party';
390
- const isIntroducer = viewerRole === 'introducer';
391
- const preferredActor = pickDisplayCounterpartActor(opportunity, state.userId)
392
- ?? opportunity.actors.find((a) => a.userId !== state.userId && a.role !== 'introducer');
393
- const actorWithProfile = opportunity.actors.find((a) => a.userId !== state.userId && a.role !== 'introducer' && !!userMap.get(a.userId));
394
- const introducer = opportunity.actors.find((a) => a.role === 'introducer');
395
- let otherActor = (preferredActor && userMap.get(preferredActor.userId))
396
- ? preferredActor
397
- : (actorWithProfile ?? preferredActor);
398
- // When the only other participant is the introducer (no separate party), use introducer as display counterpart so the card shows a name instead of "Unknown"
399
- if (!otherActor && introducer && introducer.userId !== state.userId && introducer.userId) {
400
- otherActor = { userId: introducer.userId, role: introducer.role };
401
- }
402
- const otherUser = otherActor ? userMap.get(otherActor.userId) ?? null : null;
403
- const introducerCounterparts = opportunity.actors.filter((a) => a.userId !== state.userId && a.role !== 'introducer');
404
- // Deduplicate by userId — actors array can contain multiple rows per user
405
- // (e.g. from different intents), which would produce repeated names.
406
- const uniqueCounterpartIds = [...new Set(introducerCounterparts.map((a) => a.userId))];
407
- const participantNames = uniqueCounterpartIds
408
- .map((uid) => userMap.get(uid)?.name ?? 'Unknown')
409
- .sort();
410
- // When secondPartyData will be present (2+ counterparts), use single counterpart name
411
- // because the frontend arrow layout renders "card.name → secondParty.name".
412
- // Using the joined "A ↔ B" format here would produce redundant "A ↔ B → B".
413
- // Only use the joined format when there is a single counterpart (no arrow layout).
414
- const willHaveSecondParty = isIntroducer && uniqueCounterpartIds.length > 1;
415
- let userName = isIntroducer && participantNames.length > 0 && !willHaveSecondParty
416
- ? participantNames.join(' ↔ ')
417
- : (otherUser?.name ?? 'Unknown');
418
- // Fallback to profile identity name when users.name is missing (e.g. profile has display name, users row does not)
419
- if ((userName === 'Unknown' || !userName?.trim()) && otherActor?.userId && db.getProfile) {
420
- const profile = await db.getProfile(otherActor.userId).catch((err) => {
421
- logger.debug('getProfile fallback failed', { otherActorUserId: otherActor.userId, error: err });
422
- return null;
423
- });
424
- const profileName = profile?.identity?.name?.trim();
425
- if (profileName)
426
- userName = profileName;
427
- }
428
- // Unresolvable display counterpart (deleted user: no users row, no
429
- // profile fallback). Drop the card entirely instead of rendering an
430
- // "Unknown" placeholder: such cards are unusable, excluded from the
431
- // presenter cache (see cachePresenterResults), and would otherwise
432
- // trigger a fresh presenter LLM call on every request — a permanent
433
- // cache miss that keeps the whole radar slow (~9s per load).
434
- if (userName === 'Unknown' || !userName?.trim()) {
435
- logger.verbose('[RadarGraph:generateCardText] dropping card with unresolvable counterpart', {
436
- opportunityId: opportunity.id,
437
- otherActorUserId: otherActor?.userId,
438
- });
439
- return null;
440
- }
441
- const userAvatar = otherUser?.avatar ?? null;
442
- // Shared sanitization standard (UUID strip, viewer-centric rewrite,
443
- // boundary truncation) — raw reasoning must never render verbatim.
444
- const reasoningSnippet = safeFallbackSummary(typeof opportunity.interpretation?.reasoning === 'string'
445
- ? opportunity.interpretation.reasoning
446
- : '', {
447
- counterpartName: userName !== 'Unknown' ? userName : undefined,
448
- maxChars: MAX_REASONING_SNIPPET_LENGTH,
449
- emptyText: 'A promising connection.',
450
- });
451
- // Build secondParty for introducer arrow layout (the party that isn't the display counterpart)
452
- let secondPartyData;
453
- if (isIntroducer && introducerCounterparts.length > 1 && otherActor) {
454
- const secondActor = introducerCounterparts.find((a) => a.userId !== otherActor.userId);
455
- if (secondActor) {
456
- const secondUser = userMap.get(secondActor.userId) ?? null;
457
- secondPartyData = {
458
- name: secondUser?.name ?? 'Unknown',
459
- avatar: secondUser?.avatar ?? null,
460
- userId: secondActor.userId,
461
- };
462
- }
463
- }
464
- const isCounterpartGhost = otherUser?.isGhost ?? false;
465
- // Skeleton presentation: return an identity-only card without the
466
- // presenter LLM or negotiation-context load. Name resolution and
467
- // the unresolvable-counterpart drop above still apply, so the card
468
- // set matches what the follow-up full request will return.
469
- if (state.presentation === 'skeleton') {
470
- return {
471
- opportunityId: opportunity.id,
472
- status: opportunity.status,
473
- userId: otherActor?.userId ?? '',
474
- name: userName,
475
- avatar: userAvatar,
476
- mainText: '',
477
- cta: '',
478
- primaryActionLabel: getPrimaryActionLabel(viewerRole),
479
- secondaryActionLabel: SECONDARY_ACTION_LABEL,
480
- mutualIntentsLabel: isIntroducer ? 'Connector match' : 'Shared interests',
481
- viewerRole,
482
- isGhost: isCounterpartGhost,
483
- ...(secondPartyData ? { secondParty: secondPartyData } : {}),
484
- presentationPending: true,
485
- _cardIndex: cardIndex,
486
- };
487
- }
488
- const isPendingIntroducerFallback = isIntroducer && opportunity.status !== 'latent';
489
- const fallbackCard = () => ({
490
- opportunityId: opportunity.id,
491
- status: opportunity.status,
492
- userId: otherActor?.userId ?? '',
493
- name: userName,
494
- avatar: userAvatar,
495
- mainText: reasoningSnippet,
496
- cta: isIntroducer
497
- ? (isPendingIntroducerFallback ? 'Share this introduction to get things started.' : 'Take a look and decide if this is a good match.')
498
- : 'Take a look and decide whether to reach out.',
499
- primaryActionLabel: getPrimaryActionLabel(viewerRole),
500
- secondaryActionLabel: SECONDARY_ACTION_LABEL,
501
- mutualIntentsLabel: isIntroducer ? 'Connector match' : 'Shared interests',
502
- narratorChip: isIntroducer
503
- ? { name: 'You', text: 'Worth a look.', userId: state.userId }
504
- : { name: 'Index', text: 'Worth a look.' },
505
- viewerRole,
506
- isGhost: isCounterpartGhost,
507
- ...(secondPartyData ? { secondParty: secondPartyData } : {}),
508
- _presentationFallback: true,
509
- _cardIndex: cardIndex,
510
- });
511
- try {
512
- const [ctx, negotiationContext] = await Promise.all([
513
- gatherPresenterContext(db, opportunity, state.userId, otherActor?.userId),
514
- loadNegotiationContext(db, opportunity.id, opportunity.status),
515
- ]);
516
- const presenterInput = {
517
- ...ctx,
518
- mutualIntentCount: undefined,
519
- opportunityStatus: opportunity.status,
520
- ...(negotiationContext ? { negotiationContext } : {}),
521
- };
522
- const _traceEmitterPresenter = requestContext.getStore()?.traceEmitter;
523
- const presenterStart = Date.now();
524
- _traceEmitterPresenter?.({ type: "agent_start", name: "opportunity-presenter" });
525
- const presentation = await presenter.presentCard(presenterInput);
526
- const _presenterDuration = Date.now() - presenterStart;
527
- agentTimingsAccum.push({ name: 'opportunity.presenter', durationMs: _presenterDuration });
528
- _traceEmitterPresenter?.({ type: "agent_end", name: "opportunity-presenter", durationMs: _presenterDuration, summary: `Presented: ${userName}` });
529
- if (presentation.isFallback) {
530
- return fallbackCard();
531
- }
532
- let narratorChip;
533
- // Only show a person as narrator when they are the introducer and not the display counterpart
534
- // (bad data can have same user as introducer and party, e.g. "Amina introduced you to Amina")
535
- const introducerIsCounterpart = introducer && otherActor && introducer.userId === otherActor.userId;
536
- if (introducer && introducer.userId !== state.userId && !introducerIsCounterpart) {
537
- const introUser = userMap.get(introducer.userId) ?? null;
538
- const narratorName = introUser?.name ?? 'Someone';
539
- narratorChip = {
540
- name: narratorName,
541
- text: stripLeadingNarratorName(presentation.narratorRemark, narratorName),
542
- avatar: introUser?.avatar ?? null,
543
- userId: introducer.userId,
544
- };
545
- }
546
- else if (introducer?.userId === state.userId) {
547
- narratorChip = { name: 'You', text: presentation.narratorRemark, userId: state.userId };
548
- }
549
- else {
550
- narratorChip = { name: 'Index', text: presentation.narratorRemark };
551
- }
552
- return {
553
- opportunityId: opportunity.id,
554
- status: opportunity.status,
555
- userId: otherActor?.userId ?? '',
556
- name: userName,
557
- avatar: userAvatar,
558
- mainText: presentation.personalizedSummary,
559
- cta: presentation.suggestedAction,
560
- headline: presentation.headline,
561
- primaryActionLabel: getPrimaryActionLabel(viewerRole),
562
- secondaryActionLabel: SECONDARY_ACTION_LABEL,
563
- mutualIntentsLabel: presentation.mutualIntentsLabel,
564
- narratorChip,
565
- viewerRole,
566
- isGhost: isCounterpartGhost,
567
- ...(secondPartyData ? { secondParty: secondPartyData } : {}),
568
- _cardIndex: cardIndex,
569
- };
570
- }
571
- catch (e) {
572
- logger.warn('RadarGraph presenter failed for opportunity', { opportunityId: opportunity.id, error: e });
573
- return fallbackCard();
574
- }
575
- }));
576
- cards.push(...chunkCards.filter((c) => c !== null));
577
480
  }
578
- generateCardTextLog.verbose('exit', { totalOpportunities: state.opportunities.length });
579
- return {
580
- cards,
581
- agentTimings: agentTimingsAccum,
582
- meta: { totalOpportunities: state.opportunities.length },
583
- };
584
- });
585
- };
586
- const cachePresenterResultsNode = async (state) => {
587
- return timed("RadarGraph.cachePresenterResults", async () => {
588
- const { cards, cachedCards, userId, opportunities } = state;
589
- const poolRankingProvenance = getPoolRankingProvenance(state);
590
- const liveById = new Map(opportunities.map((opportunity) => [opportunity.id, opportunity]));
591
- const cardsWithAdjustments = cards.map((card) => {
592
- const opportunity = liveById.get(card.opportunityId);
593
- const deprioritizedReason = poolRankingProvenance
594
- ? latestPoolDemotionDetail(opportunity?.metadata, poolRankingProvenance)
595
- : undefined;
596
- return { ...card, deprioritizedReason };
481
+ const isCounterpartGhost = otherUser?.isGhost ?? false;
482
+ // Skeleton presentation: return an identity-only card without the
483
+ // deps.presenter LLM or negotiation-context load. Name resolution and
484
+ // the unresolvable-counterpart drop above still apply, so the card
485
+ // set matches what the follow-up full request will return.
486
+ if (state.presentation === 'skeleton') {
487
+ return {
488
+ opportunityId: opportunity.id,
489
+ status: opportunity.status,
490
+ userId: otherActor?.userId ?? '',
491
+ name: userName,
492
+ avatar: userAvatar,
493
+ mainText: '',
494
+ cta: '',
495
+ primaryActionLabel: getPrimaryActionLabel(viewerRole),
496
+ secondaryActionLabel: SECONDARY_ACTION_LABEL,
497
+ mutualIntentsLabel: isIntroducer ? 'Connector match' : 'Shared interests',
498
+ viewerRole,
499
+ isGhost: isCounterpartGhost,
500
+ ...(secondPartyData ? { secondParty: secondPartyData } : {}),
501
+ presentationPending: true,
502
+ _cardIndex: cardIndex,
503
+ };
504
+ }
505
+ const isPendingIntroducerFallback = isIntroducer && opportunity.status !== 'latent';
506
+ const fallbackCard = () => ({
507
+ opportunityId: opportunity.id,
508
+ status: opportunity.status,
509
+ userId: otherActor?.userId ?? '',
510
+ name: userName,
511
+ avatar: userAvatar,
512
+ mainText: reasoningSnippet,
513
+ cta: isIntroducer
514
+ ? (isPendingIntroducerFallback ? 'Share this introduction to get things started.' : 'Take a look and decide if this is a good match.')
515
+ : 'Take a look and decide whether to reach out.',
516
+ primaryActionLabel: getPrimaryActionLabel(viewerRole),
517
+ secondaryActionLabel: SECONDARY_ACTION_LABEL,
518
+ mutualIntentsLabel: isIntroducer ? 'Connector match' : 'Shared interests',
519
+ narratorChip: isIntroducer
520
+ ? { name: 'You', text: 'Worth a look.', userId: state.userId }
521
+ : { name: 'Index', text: 'Worth a look.' },
522
+ viewerRole,
523
+ isGhost: isCounterpartGhost,
524
+ ...(secondPartyData ? { secondParty: secondPartyData } : {}),
525
+ _presentationFallback: true,
526
+ _cardIndex: cardIndex,
597
527
  });
598
- // Only cache cards that weren't already from cache
599
- const newCards = cardsWithAdjustments.filter((card) => !cachedCards.has(card.opportunityId));
600
- const statusById = new Map(opportunities.map((opp) => [opp.id, opp.status]));
601
528
  try {
602
- await Promise.all(newCards.map((card) => {
603
- const status = statusById.get(card.opportunityId);
604
- // Negotiating, skeleton, fallback, and unresolved-name cards are
605
- // safe for the current response but must not become 24h entries.
606
- if (!status || !isRadarPresentationCacheable(card, status))
607
- return Promise.resolve();
608
- return this.cache.set(buildRadarCardPresentationCacheKey(card.opportunityId, status, userId), card, { ttl: RADAR_CACHE_TTL });
609
- }));
529
+ const [ctx, negotiationContext] = await Promise.all([
530
+ gatherPresenterContext(db, opportunity, state.userId, otherActor?.userId),
531
+ loadNegotiationContext(db, opportunity.id, opportunity.status),
532
+ ]);
533
+ const presenterInput = {
534
+ ...ctx,
535
+ mutualIntentCount: undefined,
536
+ opportunityStatus: opportunity.status,
537
+ ...(negotiationContext ? { negotiationContext } : {}),
538
+ };
539
+ const _traceEmitterPresenter = requestContext.getStore()?.traceEmitter;
540
+ const presenterStart = Date.now();
541
+ _traceEmitterPresenter?.({ type: "agent_start", name: "opportunity-presenter" });
542
+ const presentation = await deps.presenter.presentCard(presenterInput);
543
+ const _presenterDuration = Date.now() - presenterStart;
544
+ agentTimingsAccum.push({ name: 'opportunity.presenter', durationMs: _presenterDuration });
545
+ _traceEmitterPresenter?.({ type: "agent_end", name: "opportunity-presenter", durationMs: _presenterDuration, summary: `Presented: ${userName}` });
546
+ if (presentation.isFallback) {
547
+ return fallbackCard();
548
+ }
549
+ let narratorChip;
550
+ // Only show a person as narrator when they are the introducer and not the display counterpart
551
+ // (bad data can have same user as introducer and party, e.g. "Amina introduced you to Amina")
552
+ const introducerIsCounterpart = introducer && otherActor && introducer.userId === otherActor.userId;
553
+ if (introducer && introducer.userId !== state.userId && !introducerIsCounterpart) {
554
+ const introUser = userMap.get(introducer.userId) ?? null;
555
+ const narratorName = introUser?.name ?? 'Someone';
556
+ narratorChip = {
557
+ name: narratorName,
558
+ text: stripLeadingNarratorName(presentation.narratorRemark, narratorName),
559
+ avatar: introUser?.avatar ?? null,
560
+ userId: introducer.userId,
561
+ };
562
+ }
563
+ else if (introducer?.userId === state.userId) {
564
+ narratorChip = { name: 'You', text: presentation.narratorRemark, userId: state.userId };
565
+ }
566
+ else {
567
+ narratorChip = { name: 'Index', text: presentation.narratorRemark };
568
+ }
569
+ return {
570
+ opportunityId: opportunity.id,
571
+ status: opportunity.status,
572
+ userId: otherActor?.userId ?? '',
573
+ name: userName,
574
+ avatar: userAvatar,
575
+ mainText: presentation.personalizedSummary,
576
+ cta: presentation.suggestedAction,
577
+ headline: presentation.headline,
578
+ primaryActionLabel: getPrimaryActionLabel(viewerRole),
579
+ secondaryActionLabel: SECONDARY_ACTION_LABEL,
580
+ mutualIntentsLabel: presentation.mutualIntentsLabel,
581
+ narratorChip,
582
+ viewerRole,
583
+ isGhost: isCounterpartGhost,
584
+ ...(secondPartyData ? { secondParty: secondPartyData } : {}),
585
+ _cardIndex: cardIndex,
586
+ };
610
587
  }
611
588
  catch (e) {
612
- cachePresenterResultsLog.warn('cache write failed, continuing', { error: e });
589
+ logger.warn('RadarGraph presenter failed for opportunity', { opportunityId: opportunity.id, error: e });
590
+ return fallbackCard();
613
591
  }
614
- // Merge cached cards into full card list
615
- const allCards = [...cardsWithAdjustments];
616
- for (const [oppId, cachedCard] of cachedCards) {
617
- if (!cardsWithAdjustments.some((card) => card.opportunityId === oppId)) {
618
- allCards.push(cachedCard);
619
- }
620
- }
621
- // Re-sort by _cardIndex to maintain original ordering
622
- allCards.sort((a, b) => a._cardIndex - b._cardIndex);
623
- cachePresenterResultsLog.verbose('', {
624
- newlyCached: newCards.length,
625
- totalCards: allCards.length,
626
- });
627
- return {
628
- cards: allCards,
629
- meta: { totalOpportunities: state.opportunities.length },
630
- };
631
- });
592
+ }));
593
+ cards.push(...chunkCards.filter((c) => c !== null));
594
+ }
595
+ generateCardTextLog.verbose('exit', { totalOpportunities: state.opportunities.length });
596
+ return {
597
+ cards,
598
+ agentTimings: agentTimingsAccum,
599
+ meta: { totalOpportunities: state.opportunities.length },
632
600
  };
633
- const normalizeItemsNode = async (state) => {
634
- return timed("RadarGraph.normalizeItems", async () => {
635
- normalizeItemsLog.verbose('entry', { cardsLength: state.cards.length });
636
- const items = state.cards.map((card) => {
637
- const { _cardIndex, _presentationFallback, ...rest } = card;
638
- return rest;
639
- });
640
- const meta = { totalOpportunities: state.opportunities.length };
641
- normalizeItemsLog.verbose('exit', { totalOpportunities: meta.totalOpportunities, totalItems: items.length });
642
- return { items, meta };
643
- });
601
+ });
602
+ }
603
+ export async function cachePresenterResultsNode(state, deps) {
604
+ return timed("RadarGraph.cachePresenterResults", async () => {
605
+ const { cards, cachedCards, userId, opportunities } = state;
606
+ const poolRankingProvenance = getPoolRankingProvenance(state);
607
+ const liveById = new Map(opportunities.map((opportunity) => [opportunity.id, opportunity]));
608
+ const cardsWithAdjustments = cards.map((card) => {
609
+ const opportunity = liveById.get(card.opportunityId);
610
+ const deprioritizedReason = poolRankingProvenance
611
+ ? latestPoolDemotionDetail(opportunity?.metadata, poolRankingProvenance)
612
+ : undefined;
613
+ return { ...card, deprioritizedReason };
614
+ });
615
+ // Only cache cards that weren't already from cache
616
+ const newCards = cardsWithAdjustments.filter((card) => !cachedCards.has(card.opportunityId));
617
+ const statusById = new Map(opportunities.map((opp) => [opp.id, opp.status]));
618
+ try {
619
+ await Promise.all(newCards.map((card) => {
620
+ const status = statusById.get(card.opportunityId);
621
+ // Negotiating, skeleton, fallback, and unresolved-name cards are
622
+ // safe for the current response but must not become 24h entries.
623
+ if (!status || !isRadarPresentationCacheable(card, status))
624
+ return Promise.resolve();
625
+ return deps.cache.set(buildRadarCardPresentationCacheKey(card.opportunityId, status, userId), card, { ttl: RADAR_CACHE_TTL });
626
+ }));
627
+ }
628
+ catch (e) {
629
+ cachePresenterResultsLog.warn('cache write failed, continuing', { error: e });
630
+ }
631
+ // Merge cached cards into full card list
632
+ const allCards = [...cardsWithAdjustments];
633
+ for (const [oppId, cachedCard] of cachedCards) {
634
+ if (!cardsWithAdjustments.some((card) => card.opportunityId === oppId)) {
635
+ allCards.push(cachedCard);
636
+ }
637
+ }
638
+ // Re-sort by _cardIndex to maintain original ordering
639
+ allCards.sort((a, b) => a._cardIndex - b._cardIndex);
640
+ cachePresenterResultsLog.verbose('', {
641
+ newlyCached: newCards.length,
642
+ totalCards: allCards.length,
643
+ });
644
+ return {
645
+ cards: allCards,
646
+ meta: { totalOpportunities: state.opportunities.length },
644
647
  };
645
- const graph = new StateGraph(RadarGraphState)
646
- .addNode('loadOpportunities', loadOpportunitiesNode)
647
- .addNode('checkPresenterCache', checkPresenterCacheNode)
648
- .addNode('generateCardText', generateCardTextNode)
649
- .addNode('cachePresenterResults', cachePresenterResultsNode)
650
- .addNode('normalizeItems', normalizeItemsNode)
651
- .addEdge(START, 'loadOpportunities')
652
- .addEdge('loadOpportunities', 'checkPresenterCache')
653
- .addConditionalEdges('checkPresenterCache', shouldGenerateCards, {
654
- generate: 'generateCardText',
655
- skip: 'cachePresenterResults',
656
- })
657
- .addEdge('generateCardText', 'cachePresenterResults')
658
- .addEdge('cachePresenterResults', 'normalizeItems')
659
- .addEdge('normalizeItems', END);
660
- return graph.compile();
661
- }
648
+ });
649
+ }
650
+ export async function normalizeItemsNode(state) {
651
+ return timed("RadarGraph.normalizeItems", async () => {
652
+ normalizeItemsLog.verbose('entry', { cardsLength: state.cards.length });
653
+ const items = state.cards.map((card) => {
654
+ const { _cardIndex, _presentationFallback, ...rest } = card;
655
+ return rest;
656
+ });
657
+ const meta = { totalOpportunities: state.opportunities.length };
658
+ normalizeItemsLog.verbose('exit', { totalOpportunities: meta.totalOpportunities, totalItems: items.length });
659
+ return { items, meta };
660
+ });
662
661
  }