@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
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Opportunity Presenter Agent
2
+ * The opportunity presentation cluster.
3
3
  *
4
- * Generates personalized, second-person explanations of why an opportunity
5
- * matters to the viewing user. Uses full opportunity data (interpretation,
6
- * actors, profiles, intents, index) to produce headline, personalizedSummary,
7
- * and suggestedAction for chat tools and user-facing surfaces.
4
+ * One file for the whole path from a persisted opportunity to the copy a user
5
+ * reads: the pure text transforms, the cache-key builders, the safe-fallback
6
+ * pipeline, and the LLM presenter itself. They were four modules that only ever
7
+ * called each other in one direction, and following a card's copy meant hopping
8
+ * between them.
9
+ *
10
+ * Sections, in dependency order:
11
+ * 1. Pure presentation transforms
12
+ * 2. Presentation cache keys
13
+ * 3. Safe-presentation pipeline (fallbacks that never leak raw reasoning)
14
+ * 4. OpportunityPresenter (LLM card and chat copy)
8
15
  */
9
16
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
10
17
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -15,15 +22,640 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
15
22
  var __metadata = (this && this.__metadata) || function (k, v) {
16
23
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
17
24
  };
25
+ import { MINIMAL_MAIN_TEXT_MAX_CHARS } from "../domain/opportunity.labels.js";
26
+ import { stripUnsupportedOpportunityClaims } from "../../shared/utils/claim-safety.js";
18
27
  import { HumanMessage, SystemMessage } from "@langchain/core/messages";
19
28
  import { z } from "zod";
20
29
  import { Timed } from "../../shared/observability/performance.js";
21
30
  import { protocolLogger } from "../../shared/observability/protocol.logger.js";
22
31
  import { createStructuredModel } from "../../shared/agent/model.config.js";
23
- import { viewerCentricCardSummary } from "../domain/opportunity.presentation.js";
24
- import { stripUuids, stripIntroducerMentions } from "../domain/opportunity.presentation.js";
25
- import { stripUnsupportedOpportunityClaims } from "../../shared/utils/claim-safety.js";
26
- import { DEFAULT_EMPTY_FALLBACK_TEXT, DEFAULT_FALLBACK_ACTION, DEFAULT_FALLBACK_HEADLINE, safeFallbackSummary } from "../domain/opportunity.safe-presentation.js";
32
+ /**
33
+ * Generate presentation copy for an opportunity based on viewer context.
34
+ * Pure function no side effects, no database access.
35
+ */
36
+ export function presentOpportunity(opp, viewerId, otherPartyInfo, introducerInfo, format) {
37
+ const myActor = opp.actors.find((a) => a.userId === viewerId);
38
+ const introducer = opp.actors.find((a) => a.role === 'introducer');
39
+ if (!myActor) {
40
+ throw new Error('Viewer is not an actor in this opportunity');
41
+ }
42
+ const otherName = otherPartyInfo.name;
43
+ const safeReasoning = stripUnsupportedOpportunityClaims(stripUuids(opp.interpretation.reasoning)) ||
44
+ 'A promising connection.';
45
+ let title;
46
+ let description;
47
+ let descriptionIsReasoning = false;
48
+ switch (myActor.role) {
49
+ case 'agent':
50
+ title = `You can help ${otherName}`;
51
+ description = `Based on your expertise, ${otherName} might benefit from connecting with you.`;
52
+ break;
53
+ case 'patient':
54
+ title = `${otherName} might be able to help you`;
55
+ description = `${otherName} has skills that align with what you're looking for.`;
56
+ break;
57
+ case 'peer':
58
+ title = `Potential collaboration with ${otherName}`;
59
+ description = `You and ${otherName} have complementary interests.`;
60
+ break;
61
+ case 'mentee':
62
+ title = `${otherName} could mentor you`;
63
+ description = `${otherName} has experience that could help guide your journey.`;
64
+ break;
65
+ case 'mentor':
66
+ title = `${otherName} is looking for guidance`;
67
+ description = `Your expertise could help ${otherName} on their path.`;
68
+ break;
69
+ case 'founder':
70
+ title = `${otherName} might be interested in your venture`;
71
+ description = `${otherName}'s investment focus aligns with what you're building.`;
72
+ break;
73
+ case 'investor':
74
+ title = `${otherName} is building something interesting`;
75
+ description = `${otherName}'s venture might fit your investment thesis.`;
76
+ break;
77
+ case 'party':
78
+ default:
79
+ if (introducer && introducerInfo) {
80
+ title = `${introducerInfo.name} thinks you should meet ${otherName}`;
81
+ description = safeReasoning;
82
+ descriptionIsReasoning = true;
83
+ }
84
+ else {
85
+ title = `Opportunity with ${otherName}`;
86
+ description = safeReasoning;
87
+ descriptionIsReasoning = true;
88
+ }
89
+ break;
90
+ }
91
+ if (!descriptionIsReasoning) {
92
+ description += `\n\n${safeReasoning}`;
93
+ }
94
+ if (format === 'notification') {
95
+ description =
96
+ description.length > 100 ? description.slice(0, 97) + '...' : description;
97
+ }
98
+ return {
99
+ title,
100
+ description,
101
+ callToAction: 'View Opportunity',
102
+ };
103
+ }
104
+ /**
105
+ * Strips UUID patterns from user-facing text to prevent internal ID leaks.
106
+ */
107
+ const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
108
+ export function stripUuids(text) {
109
+ return text
110
+ .replace(/\(([^)]*)\)/g, (_match, inner) => {
111
+ if (!UUID_PATTERN.test(inner)) {
112
+ UUID_PATTERN.lastIndex = 0;
113
+ return _match;
114
+ }
115
+ UUID_PATTERN.lastIndex = 0;
116
+ const cleaned = inner
117
+ .replace(UUID_PATTERN, '')
118
+ .replace(/,\s*,/g, ',')
119
+ .replace(/\b(?:from|and)\b/gi, '')
120
+ .replace(/^[\s,]+|[\s,]+$/g, '');
121
+ return cleaned ? `(${cleaned})` : '';
122
+ })
123
+ .replace(UUID_PATTERN, '')
124
+ .replace(/\s{2,}/g, ' ')
125
+ .trim();
126
+ }
127
+ /**
128
+ * Truncate user-facing text to at most `maxChars` without cutting mid-word.
129
+ *
130
+ * Prefers a sentence boundary, then a word boundary, and only falls back to a
131
+ * hard slice if no boundary exists within the limit. An ellipsis is appended
132
+ * when the text is actually shortened. Used by presenter fallbacks so a degraded
133
+ * card never shows a sentence chopped mid-word (e.g. "His focus on 'indiv").
134
+ */
135
+ export function truncateAtBoundary(text, maxChars) {
136
+ const trimmed = text.trim();
137
+ if (trimmed.length <= maxChars)
138
+ return trimmed;
139
+ const slice = trimmed.slice(0, maxChars);
140
+ // Prefer ending on the last completed sentence within the limit.
141
+ const lastSentence = Math.max(slice.lastIndexOf(". "), slice.lastIndexOf("! "), slice.lastIndexOf("? "));
142
+ if (lastSentence >= maxChars * 0.5) {
143
+ return slice.slice(0, lastSentence + 1).trim();
144
+ }
145
+ // Otherwise back off to the last whole word and add an ellipsis.
146
+ const lastSpace = slice.lastIndexOf(" ");
147
+ const body = lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
148
+ return body.replace(/[\s,;:.!?'"-]+$/, "").trim() + "\u2026";
149
+ }
150
+ /**
151
+ * Strips introducer mentions from opportunity summary text.
152
+ * Removes patterns like:
153
+ * - "[Introducer] introduced you to [Counterpart]"
154
+ * - "[Introducer] thinks you should meet [Counterpart]"
155
+ * - "[Introducer] connected you to [Counterpart]"
156
+ * - "[Introducer] suggested you meet [Counterpart]"
157
+ *
158
+ * @param text - The text to clean (personalizedSummary)
159
+ * @param introducerName - Full name of the introducer to strip
160
+ * @returns Text with introducer mentions removed, counterpart preserved
161
+ */
162
+ export function stripIntroducerMentions(text, introducerName) {
163
+ if (!introducerName?.trim())
164
+ return text;
165
+ const fullName = introducerName.trim();
166
+ const firstName = fullName.split(/\s+/)[0];
167
+ const namesToCheck = [fullName];
168
+ if (firstName && firstName.length > 1) {
169
+ namesToCheck.push(firstName);
170
+ }
171
+ let result = text;
172
+ for (const [idx, name] of namesToCheck.entries()) {
173
+ const escapedName = escapeRegex(name);
174
+ // Pattern: "Name introduced you to " (with or without comma, optionally with "directly")
175
+ result = result.replace(new RegExp(`\\b${escapedName}\\s+introduced\\s+you\\s+(?:directly\\s+)?to\\s*`, "gi"), "");
176
+ // Pattern: "Name thinks you should meet "
177
+ result = result.replace(new RegExp(`\\b${escapedName}\\s+thinks\\s+you\\s+should\\s+meet\\s*`, "gi"), "");
178
+ // Pattern: "Name connected you to "
179
+ result = result.replace(new RegExp(`\\b${escapedName}\\s+connected\\s+you\\s+(?:to|with)\\s*`, "gi"), "");
180
+ // Pattern: "Name suggested you meet "
181
+ result = result.replace(new RegExp(`\\b${escapedName}\\s+suggested\\s+you\\s+(?:meet|connect\\s+(?:to|with))\\s*`, "gi"), "");
182
+ // Pattern: "Name recommended you meet "
183
+ result = result.replace(new RegExp(`\\b${escapedName}\\s+recommended\\s+you\\s+(?:meet|connect)\\s*`, "gi"), "");
184
+ // Pattern: "Name thinks you and Counterpart should meet" -> remove entire phrase up to Counterpart
185
+ result = result.replace(new RegExp(`\\b${escapedName}\\s+thinks\\s+you\\s+and\\s+`, "gi"), "");
186
+ // Pattern: "Name also thought..." - remove sentences starting with Name + also/also thought
187
+ result = result.replace(new RegExp(`\\b${escapedName}\\s+(?:also\\s+)?(?:thought|thinks?|believes?|felt)\\s*`, "gi"), "");
188
+ // General: Remove any remaining standalone mention of the introducer name at sentence start.
189
+ // Only apply for fullName (idx === 0) to avoid stripping valid counterpart first names
190
+ // (e.g. "David Smith" intro to "David Johnson" → we strip "David Smith" but not "David" in "David Johnson").
191
+ if (idx === 0) {
192
+ result = result.replace(new RegExp(`(?:^|\\.\\s*)\\b${escapedName}\\s+`, "gi"), (match, offset) => {
193
+ if (offset === 0 || match.startsWith(".")) {
194
+ return match.startsWith(".") ? ". " : "";
195
+ }
196
+ return match;
197
+ });
198
+ }
199
+ }
200
+ // Clean up: remove leading/trailing whitespace and common punctuation artifacts
201
+ result = result
202
+ .replace(/^[\,\s]+/, "") // Remove leading commas/spaces
203
+ .replace(/\s{2,}/g, " ") // Normalize multiple spaces
204
+ .trim();
205
+ // Capitalize first letter if we removed from start
206
+ if (result.length > 0) {
207
+ result = result.charAt(0).toUpperCase() + result.slice(1);
208
+ }
209
+ return result;
210
+ }
211
+ // Helper function
212
+ function escapeRegex(s) {
213
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
214
+ }
215
+ /**
216
+ * Viewer-centric text for opportunity cards.
217
+ * The card is shown to the viewer (logged-in user) and should introduce the
218
+ * counterpart, not describe the viewer to themselves.
219
+ */
220
+ /**
221
+ * Splits text into sentences using (?<=[.!?])\s+ (period/exclamation/question followed by whitespace).
222
+ * Note: splits after any such punctuation, including abbreviations like "Dr." or "e.g.".
223
+ */
224
+ function splitSentences(text) {
225
+ const trimmed = text.trim();
226
+ if (!trimmed)
227
+ return [];
228
+ return trimmed
229
+ .split(/(?<=[.!?])\s+/)
230
+ .map((s) => s.trim())
231
+ .filter(Boolean);
232
+ }
233
+ /**
234
+ * Returns viewer-centric main text for an opportunity card.
235
+ * Prefers the part of the reasoning that describes the counterpart (the person
236
+ * on the card), so the viewer sees an introduction to the counterpart rather
237
+ * than a description of themselves.
238
+ *
239
+ * @param reasoning - Raw interpretation.reasoning (may describe both parties).
240
+ * @param counterpartName - Display name of the suggested connection (e.g. "Alex Chen").
241
+ * @param maxChars - Max length of returned string (default MINIMAL_MAIN_TEXT_MAX_CHARS).
242
+ * @param viewerName - Optional display name of the viewer (signed-in user). When provided, sentences or prefixes describing the viewer are skipped so the card introduces the counterpart, not the viewer.
243
+ * @param introducerName - Optional display name of the introducer. When provided, introducer phrases (e.g., "X introduced you to...") are stripped from the summary to keep the body text focused on match quality.
244
+ * @returns Viewer-centric snippet mentioning the counterpart when possible; if counterpartName is empty, returns reasoning truncated to maxChars. Never null; may be "A suggested connection." when reasoning is empty.
245
+ */
246
+ export function viewerCentricCardSummary(reasoning, counterpartName, maxChars = MINIMAL_MAIN_TEXT_MAX_CHARS, viewerName, introducerName) {
247
+ const raw = stripUnsupportedOpportunityClaims(stripUuids(reasoning));
248
+ if (!raw)
249
+ return "A suggested connection.";
250
+ const name = counterpartName.trim();
251
+ if (!name) {
252
+ let out = raw.length <= maxChars ? raw : raw.slice(0, maxChars) + "...";
253
+ // Strip introducer mentions BEFORE replacing viewer name to avoid "you introduced..." artifacts
254
+ if (introducerName) {
255
+ out = stripIntroducerMentions(out, introducerName);
256
+ }
257
+ out = replaceViewerNameWithYou(out, viewerName);
258
+ return out;
259
+ }
260
+ const sentences = splitSentences(raw);
261
+ const nameLower = name.toLowerCase();
262
+ const firstWordOfName = name.split(/\s+/)[0]?.toLowerCase();
263
+ const hasCounterpartName = (s) => s.toLowerCase().includes(nameLower) ||
264
+ (firstWordOfName && firstWordOfName.length > 1 && s.toLowerCase().includes(firstWordOfName));
265
+ const viewer = viewerName?.trim().toLowerCase();
266
+ const viewerFirstWord = viewerName?.trim().split(/\s+/)[0]?.toLowerCase();
267
+ const startsWithViewer = (s) => {
268
+ if (!viewer)
269
+ return false;
270
+ const sl = s.toLowerCase();
271
+ return sl.startsWith(viewer) ||
272
+ (viewerFirstWord && viewerFirstWord.length > 1 && sl.startsWith(viewerFirstWord));
273
+ };
274
+ // When viewerName is provided, prefer sentences that mention the counterpart
275
+ // but do NOT start with the viewer's name.
276
+ if (viewer) {
277
+ // First pass: find a sentence that mentions counterpart and doesn't start with viewer
278
+ const cleanIdx = sentences.findIndex((s) => hasCounterpartName(s) && !startsWithViewer(s));
279
+ if (cleanIdx !== -1) {
280
+ const result = sentences.slice(cleanIdx).join(" ").trim();
281
+ let out = result.length <= maxChars ? result : result.slice(0, maxChars) + "...";
282
+ // Strip introducer mentions BEFORE replacing viewer name to avoid "you introduced..." artifacts
283
+ if (introducerName) {
284
+ out = stripIntroducerMentions(out, introducerName);
285
+ }
286
+ out = replaceViewerNameWithYou(out, viewerName, [name]);
287
+ return out;
288
+ }
289
+ // Second pass: sentence mentions counterpart but starts with viewer (compound sentence).
290
+ // Try to extract the counterpart portion after the counterpart's name.
291
+ const compoundIdx = sentences.findIndex((s) => hasCounterpartName(s) && startsWithViewer(s));
292
+ if (compoundIdx !== -1) {
293
+ const sentence = sentences[compoundIdx];
294
+ // Find where the counterpart name appears and extract from there
295
+ // Use case-insensitive Unicode-aware regex so the index is correct
296
+ // even when toLowerCase() changes string length (e.g. Turkish İ→i, German ß→ss).
297
+ const cpMatch = sentence.match(new RegExp(escapeRegex(name), "iu"));
298
+ const cpIdx = cpMatch?.index ?? -1;
299
+ if (cpIdx > 0) {
300
+ const extracted = sentence.slice(cpIdx).trim();
301
+ const rest = sentences.slice(compoundIdx + 1).join(" ").trim();
302
+ const result = rest ? `${extracted} ${rest}` : extracted;
303
+ let out = result.length <= maxChars ? result : result.slice(0, maxChars) + "...";
304
+ // Strip introducer mentions BEFORE replacing viewer name to avoid "you introduced..." artifacts
305
+ if (introducerName) {
306
+ out = stripIntroducerMentions(out, introducerName);
307
+ }
308
+ out = replaceViewerNameWithYou(out, viewerName, [name]);
309
+ return out;
310
+ }
311
+ }
312
+ }
313
+ // Fallback: original logic without viewer awareness
314
+ const idx = sentences.findIndex(hasCounterpartName);
315
+ if (idx === -1) {
316
+ let out = raw.length <= maxChars ? raw : raw.slice(0, maxChars) + "...";
317
+ // Strip introducer mentions BEFORE replacing viewer name to avoid "you introduced..." artifacts
318
+ if (introducerName) {
319
+ out = stripIntroducerMentions(out, introducerName);
320
+ }
321
+ out = replaceViewerNameWithYou(out, viewerName, [name]);
322
+ return out;
323
+ }
324
+ const fromCounterpart = sentences.slice(idx).join(" ").trim();
325
+ let out = fromCounterpart.length <= maxChars
326
+ ? fromCounterpart
327
+ : fromCounterpart.slice(0, maxChars) + "...";
328
+ // Strip introducer mentions BEFORE replacing viewer name to avoid "you introduced..." artifacts
329
+ if (introducerName) {
330
+ out = stripIntroducerMentions(out, introducerName);
331
+ }
332
+ out = replaceViewerNameWithYou(out, viewerName, [name]);
333
+ return out;
334
+ }
335
+ /** Max length for narrator chip text (matches LLM presenter schema). */
336
+ const NARRATOR_MAX_CHARS = 80;
337
+ const FALLBACK_REMARK = "A potential connection worth exploring.";
338
+ /**
339
+ * Generates a short narrator remark from opportunity reasoning for the narrator chip.
340
+ * Used by the minimal (no-LLM) card path so each card gets a unique remark
341
+ * instead of the same static text.
342
+ *
343
+ * Extracts domain keywords (e.g. "AI", "design", "machine learning") from the
344
+ * reasoning and frames them in a short template like "Shared interest in AI and design."
345
+ *
346
+ * This is a regex-based heuristic — an alternative is OpportunityPresenter.presentCard()
347
+ * which generates narratorRemark via LLM with much higher quality (already used by
348
+ * home.graph.ts and opportunity.discover.ts). See buildMinimalOpportunityCard() in
349
+ * opportunity.tools.ts for the trade-off discussion.
350
+ *
351
+ * @param reasoning - Raw interpretation.reasoning text.
352
+ * @param counterpartName - Display name of the counterpart (stripped from output).
353
+ * @param viewerName - Optional display name of the viewer (stripped from output).
354
+ * @returns A short remark (max ~80 chars) suitable for the narrator chip. Never truncated with "...".
355
+ */
356
+ export function narratorRemarkFromReasoning(reasoning, counterpartName, viewerName) {
357
+ const raw = stripUnsupportedOpportunityClaims(stripUuids(reasoning)).trim();
358
+ if (!raw)
359
+ return FALLBACK_REMARK;
360
+ // Strip all person names from the text so we work only with topics.
361
+ let cleaned = raw;
362
+ for (const name of [counterpartName, viewerName]) {
363
+ if (!name?.trim())
364
+ continue;
365
+ const full = name.trim();
366
+ cleaned = cleaned.replace(new RegExp(escapeRegex(full), "gi"), "").trim();
367
+ const first = full.split(/\s+/)[0];
368
+ if (first && first.length > 1) {
369
+ cleaned = cleaned.replace(new RegExp(`\\b${escapeRegex(first)}\\b`, "gi"), "").trim();
370
+ }
371
+ }
372
+ // Extract domain/topic noun phrases from the cleaned text.
373
+ // Match multi-word capitalized phrases (e.g. "AI operations toolkit") and
374
+ // known domain terms.
375
+ const domainTerms = extractDomainTerms(cleaned);
376
+ if (domainTerms.length > 0) {
377
+ // Build "Shared interest in X and Y." or "Overlap in X, Y, and Z."
378
+ const prefixes = [
379
+ "Shared interest in",
380
+ "Overlap in",
381
+ "Common ground in",
382
+ "Aligned on",
383
+ "Mutual interest in",
384
+ ];
385
+ // Pick prefix deterministically based on first term's char code
386
+ const prefixIdx = domainTerms[0].charCodeAt(0) % prefixes.length;
387
+ const prefix = prefixes[prefixIdx];
388
+ const joined = joinTerms(domainTerms, NARRATOR_MAX_CHARS - prefix.length - 2); // -2 for " " and "."
389
+ const remark = `${prefix} ${joined}.`;
390
+ if (remark.length <= NARRATOR_MAX_CHARS)
391
+ return remark;
392
+ }
393
+ // Fallback: try to extract a short relationship phrase
394
+ const relationshipMatch = cleaned.match(/\b(complementary skills|shared expertise|overlapping intents|similar interests|strong match|mutual fit|potential collaboration|looking for (?:a |an )?[\w\s]{3,20})\b/i);
395
+ if (relationshipMatch) {
396
+ const phrase = relationshipMatch[0];
397
+ const remark = `Spotted ${phrase.toLowerCase()}.`;
398
+ if (remark.length <= NARRATOR_MAX_CHARS)
399
+ return remark;
400
+ }
401
+ return FALLBACK_REMARK;
402
+ }
403
+ /**
404
+ * Extracts domain/topic terms from text by matching known patterns:
405
+ * - Acronyms (AI, ML, UX, API)
406
+ * - Multi-word domain phrases (machine learning, game development)
407
+ * - Capitalized proper nouns that look like topics
408
+ */
409
+ function extractDomainTerms(text) {
410
+ const seen = new Set();
411
+ const terms = [];
412
+ // Known domain phrases (order matters — longer first)
413
+ const knownPhrases = [
414
+ /\b(machine learning|artificial intelligence|software development|game development|web development|data science|deep learning|natural language processing|computer vision|cloud computing|mobile development|product design|user experience|graphic design|character design|frontend development|backend development|full[- ]stack|smart contracts|visual art|creative writing|content creation|digital marketing|venture capital|angel invest(?:ing|ment)|open source|blockchain|cryptocurrency|decentralized finance|social impact|community building|music production|film(?:making| production)|photography|illustration|animation|3D modeling|startup|co-?founding|entrepreneurship|research|consulting|mentoring|freelanc(?:e|ing))\b/gi,
415
+ /\b(AI|ML|UX|UI|API|NLP|SaaS|DeFi|DevOps|DeSci|NFT|DAO|React|Node|Python|TypeScript|JavaScript|Rust|Solidity|Go|Swift|Kotlin|Figma|Blender|Unity|Unreal)\b/g,
416
+ ];
417
+ for (const pattern of knownPhrases) {
418
+ for (const match of text.matchAll(pattern)) {
419
+ const term = match[1] ?? match[0];
420
+ const key = term.toLowerCase();
421
+ if (!seen.has(key)) {
422
+ seen.add(key);
423
+ // Preserve case for short acronyms/proper nouns; lowercase multi-word phrases
424
+ if (term.length <= 5 && /^[A-Z]/.test(term)) {
425
+ terms.push(term); // Keep React, AI, ML, etc. as-is
426
+ }
427
+ else {
428
+ terms.push(key);
429
+ }
430
+ }
431
+ }
432
+ }
433
+ // If no known phrases found, look for capitalized multi-word phrases
434
+ // that look like explicit topic references (e.g. "Visual Art", "Smart Contracts").
435
+ // Only accept capitalized words to avoid grabbing meta-language from evaluator reasoning
436
+ // (e.g. "discoverer", "explicitly", "states" which are about the matching process, not topics).
437
+ if (terms.length === 0) {
438
+ // Multi-word capitalized phrases first (e.g. "Visual Art", "Creative Writing")
439
+ const multiWordPattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/g;
440
+ for (const match of text.matchAll(multiWordPattern)) {
441
+ const term = match[1];
442
+ const key = term.toLowerCase();
443
+ if (!seen.has(key)) {
444
+ seen.add(key);
445
+ terms.push(key);
446
+ if (terms.length >= 3)
447
+ break;
448
+ }
449
+ }
450
+ // Single capitalized words as last resort (skip common sentence-starters and meta-words)
451
+ if (terms.length === 0) {
452
+ const skipCapitalized = new Set([
453
+ // Articles / conjunctions / prepositions (capitalized at sentence start)
454
+ "the", "and", "but", "for", "from", "with", "without", "between",
455
+ "into", "about", "after", "before", "over", "under", "through",
456
+ // Common sentence starters / pronouns / determiners
457
+ "both", "their", "they", "this", "that", "these", "those",
458
+ "here", "there", "would", "could", "should", "also", "very",
459
+ "one", "another", "other", "each", "some", "many", "most",
460
+ "such", "clear", "high", "good", "well", "just", "even",
461
+ // Generic matching/relationship language
462
+ "strong", "match", "based", "making", "looking", "seeking",
463
+ "connection", "relationship", "opportunity", "overlap",
464
+ "complementary", "potential", "interested", "collaborate",
465
+ // Evaluator meta-language (about the matching process, not topics)
466
+ "intent", "intents", "profile", "user", "users", "person",
467
+ "discoverer", "explicitly", "states", "expressed", "mentioned",
468
+ "indicates", "suggests", "demonstrates", "describes", "involves",
469
+ "inference", "preparatory", "sincerity", "evaluator", "classifier",
470
+ "semantic", "pragmatic", "verification", "reconciliation",
471
+ "assertive", "commissive", "directive", "illocutionary",
472
+ "felicity", "utterance", "detected", "analysis", "confirmed",
473
+ "genuine", "conditions", "determined",
474
+ // Discourse markers
475
+ "particularly", "specifically", "especially", "primarily",
476
+ "overall", "furthermore", "however", "therefore", "moreover",
477
+ ]);
478
+ const capWords = text.match(/\b[A-Z][a-z]{2,}\b/g) ?? [];
479
+ for (const w of capWords) {
480
+ const key = w.toLowerCase();
481
+ if (!skipCapitalized.has(key) && !seen.has(key)) {
482
+ seen.add(key);
483
+ terms.push(key);
484
+ if (terms.length >= 3)
485
+ break;
486
+ }
487
+ }
488
+ }
489
+ }
490
+ return terms.slice(0, 3); // Max 3 terms
491
+ }
492
+ /** Joins terms into "X, Y, and Z" form, dropping terms if too long. */
493
+ function joinTerms(terms, maxLen) {
494
+ if (terms.length === 1)
495
+ return terms[0];
496
+ // Try all terms first
497
+ for (let count = terms.length; count >= 1; count--) {
498
+ const subset = terms.slice(0, count);
499
+ let joined;
500
+ if (subset.length === 1) {
501
+ joined = subset[0];
502
+ }
503
+ else if (subset.length === 2) {
504
+ joined = `${subset[0]} and ${subset[1]}`;
505
+ }
506
+ else {
507
+ joined = `${subset.slice(0, -1).join(", ")}, and ${subset[subset.length - 1]}`;
508
+ }
509
+ if (joined.length <= maxLen)
510
+ return joined;
511
+ }
512
+ return terms[0].slice(0, maxLen);
513
+ }
514
+ /**
515
+ * Replaces viewer's name with "you"/"your" so the card addresses the viewer in second person.
516
+ * Applied to mainText when viewerName is provided.
517
+ * @param otherNames - Other actor names in the card; first-name replacement is
518
+ * skipped when the viewer's first name matches any other actor's first name.
519
+ */
520
+ function replaceViewerNameWithYou(text, viewerName, otherNames) {
521
+ if (!viewerName?.trim())
522
+ return text;
523
+ const full = viewerName.trim();
524
+ const first = full.split(/\s+/)[0];
525
+ let out = text;
526
+ // Possessive: "Yankı's" → "your", "Yankı Ekin Yüksel's" → "your"
527
+ out = out.replace(new RegExp(`\\b${escapeRegex(full)}'s\\b`, "gi"), "your");
528
+ const otherFirstNames = (otherNames ?? [])
529
+ .map(n => n.trim().split(/\s+/)[0]?.toLowerCase())
530
+ .filter(Boolean);
531
+ const firstNameCollides = first && otherFirstNames.includes(first.toLowerCase());
532
+ if (first && first.length > 1 && !firstNameCollides) {
533
+ out = out.replace(new RegExp(`\\b${escapeRegex(first)}'s\\b`, "gi"), "your");
534
+ }
535
+ // Standalone: full name then first name so we don't break "Yankı Ekin Yüksel"
536
+ out = out.replace(new RegExp(`\\b${escapeRegex(full)}\\b`, "gi"), "you");
537
+ if (first && first.length > 1 && !firstNameCollides) {
538
+ out = out.replace(new RegExp(`\\b${escapeRegex(first)}\\b`, "gi"), "you");
539
+ }
540
+ return out;
541
+ }
542
+ // ──────────────────────────────────────────────────────────────────────
543
+ // ── 2. Presentation cache keys ──
544
+ // ──────────────────────────────────────────────────────────────────────
545
+ /** Cache namespace for opportunity presentation copy. Bump to invalidate copy safety changes. */
546
+ export const OPPORTUNITY_PRESENTATION_CACHE_VERSION = "v2";
547
+ export function buildRadarCardPresentationCacheKey(opportunityId, status, viewerId) {
548
+ return `radar:${OPPORTUNITY_PRESENTATION_CACHE_VERSION}:card:${opportunityId}:${status}:${viewerId}`;
549
+ }
550
+ export function buildDeliveryCardPresentationCacheKey(opportunityId, status, viewerId) {
551
+ return `delivery:${OPPORTUNITY_PRESENTATION_CACHE_VERSION}:card:${opportunityId}:${status}:${viewerId}`;
552
+ }
553
+ export function buildApiChatCardPresentationCacheKey(opportunityId, viewerId) {
554
+ return `chat:${OPPORTUNITY_PRESENTATION_CACHE_VERSION}:card:${opportunityId}:${viewerId}`;
555
+ }
556
+ // ──────────────────────────────────────────────────────────────────────
557
+ // ── 3. Safe-presentation pipeline ──
558
+ // ──────────────────────────────────────────────────────────────────────
559
+ /**
560
+ * Shared safe-presentation primitive for all user-facing opportunity surfaces.
561
+ *
562
+ * Historically every surface (radar, list/discover cards, minimal chat
563
+ * cards, notification emails/Telegram, chat context, delivery cards) invented
564
+ * its own fallback chain for the case where genuine LLM presenter output is
565
+ * unavailable — some sliced raw `interpretation.reasoning` with no
566
+ * sanitization at all. This module is the single standard:
567
+ *
568
+ * raw reasoning
569
+ * → whitespace-normalize
570
+ * → viewer-centric rewrite (incl. UUID stripping + introducer-mention stripping)
571
+ * → boundary-aware truncation
572
+ * → per-surface empty-text default
573
+ *
574
+ * Surfaces choose *policy* (send a sanitized fallback vs skip entirely) via
575
+ * `allowFallback`; they no longer choose (or forget) sanitization steps.
576
+ *
577
+ * See `packages/protocol/src/opportunity/AGENTS.md` for the review checklist this
578
+ * module exists to satisfy.
579
+ */
580
+ /** Default max length for fallback summaries (matches presenter internal fallback). */
581
+ export const SAFE_FALLBACK_MAX_CHARS = 300;
582
+ /** Default copy when no reasoning text is available at all. */
583
+ export const DEFAULT_EMPTY_FALLBACK_TEXT = "A promising connection.";
584
+ /** Default headline for fallback presentations (matches presenter internal fallback). */
585
+ export const DEFAULT_FALLBACK_HEADLINE = "A promising connection";
586
+ /** Default CTA for fallback presentations (matches presenter internal fallback). */
587
+ export const DEFAULT_FALLBACK_ACTION = "Take a look and decide whether to reach out.";
588
+ /**
589
+ * Produce safe user-facing fallback copy from raw match reasoning.
590
+ *
591
+ * This is the ONE sanitization standard: UUID stripping, introducer-mention
592
+ * stripping, and viewer-centric rewrite (via {@link viewerCentricCardSummary}),
593
+ * followed by whitespace normalization and boundary-aware truncation (via
594
+ * {@link truncateAtBoundary}). Never returns raw reasoning verbatim beyond
595
+ * these guarantees, and never returns an empty string.
596
+ *
597
+ * @param rawReasoning - Raw `interpretation.reasoning` / `matchReason` text (may be null/undefined).
598
+ * @param opts - Per-surface knobs (names for rewrite, max length, empty-text copy).
599
+ */
600
+ export function safeFallbackSummary(rawReasoning, opts = {}) {
601
+ const emptyText = opts.emptyText ?? DEFAULT_EMPTY_FALLBACK_TEXT;
602
+ const maxChars = opts.maxChars ?? SAFE_FALLBACK_MAX_CHARS;
603
+ const normalized = (rawReasoning ?? "").replace(/\s+/g, " ").trim();
604
+ if (!normalized)
605
+ return emptyText;
606
+ const claimSafeInput = stripUnsupportedOpportunityClaims(normalized);
607
+ if (!claimSafeInput)
608
+ return emptyText;
609
+ // viewerCentricCardSummary handles UUID stripping, introducer-mention
610
+ // stripping, and the viewer-centric rewrite. Pass Infinity so truncation is
611
+ // handled by boundary-aware logic below instead of a mid-word hard slice.
612
+ const rewritten = viewerCentricCardSummary(claimSafeInput, opts.counterpartName ?? "", Number.POSITIVE_INFINITY, opts.viewerName, opts.introducerName ?? undefined);
613
+ // Claim validation intentionally runs after viewer-centric rewriting: rewrite
614
+ // heuristics may select or join different source sentences, and the final
615
+ // user-facing sentence set is what must be safe.
616
+ const claimSafe = stripUnsupportedOpportunityClaims(rewritten);
617
+ const truncated = truncateAtBoundary(claimSafe, maxChars);
618
+ return truncated || emptyText;
619
+ }
620
+ /**
621
+ * Resolve the safe user-facing presentation for an opportunity, or signal skip.
622
+ *
623
+ * Resolution order:
624
+ * 1. Genuine presenter output (`homeCardPresentation` present, non-empty, and
625
+ * NOT tagged `isFallback` by the presenter) — claim-validated before return.
626
+ * 2. Otherwise, if `allowFallback` (default true): sanitized fallback copy
627
+ * built from `matchReason` / `interpretation.reasoning` via
628
+ * {@link safeFallbackSummary}.
629
+ * 3. Otherwise `null` — the surface must skip this opportunity.
630
+ *
631
+ * Raw `interpretation.reasoning` / `matchReason` never reaches the caller
632
+ * unsanitized through this function.
633
+ */
634
+ export function getSafePresentationOrSkip(source, opts = {}) {
635
+ const candidate = source.homeCardPresentation;
636
+ if (candidate?.personalizedSummary?.trim() && !candidate.isFallback) {
637
+ const summary = stripUnsupportedOpportunityClaims(candidate.personalizedSummary);
638
+ if (summary) {
639
+ return {
640
+ headline: stripUnsupportedOpportunityClaims(candidate.headline) ||
641
+ DEFAULT_FALLBACK_HEADLINE,
642
+ summary,
643
+ suggestedAction: stripUnsupportedOpportunityClaims(candidate.suggestedAction) ||
644
+ DEFAULT_FALLBACK_ACTION,
645
+ isFallback: false,
646
+ };
647
+ }
648
+ }
649
+ if (opts.allowFallback === false)
650
+ return null;
651
+ const rawReasoning = source.matchReason ?? source.interpretation?.reasoning ?? "";
652
+ return {
653
+ headline: DEFAULT_FALLBACK_HEADLINE,
654
+ summary: safeFallbackSummary(rawReasoning, opts),
655
+ suggestedAction: DEFAULT_FALLBACK_ACTION,
656
+ isFallback: true,
657
+ };
658
+ }
27
659
  const presentLog = protocolLogger("OpportunityPresenter:present");
28
660
  const presentCardLog = protocolLogger("OpportunityPresenter:presentCard");
29
661
  const LLM_TIMEOUT_MS = 20000;
@@ -734,3 +1366,97 @@ export async function gatherPresenterContext(database, opportunity, viewerId, di
734
1366
  };
735
1367
  return result;
736
1368
  }
1369
+ // ──────────────────────────────────────────────────────────────────────
1370
+ // ── 5. MCP card prose ──
1371
+ // ──────────────────────────────────────────────────────────────────────
1372
+ const CODE_FENCE = String.fromCharCode(96, 96, 96);
1373
+ function sanitizeJsonForCodeFence(json) {
1374
+ return json.replace(/`/g, '\\u0060');
1375
+ }
1376
+ function sanitizeOpportunityCardProse(card) {
1377
+ const sanitized = { ...card };
1378
+ for (const key of ['mainText', 'digestSummary', 'headline', 'cta', 'mutualIntentsLabel']) {
1379
+ const value = card[key];
1380
+ if (typeof value === 'string') {
1381
+ sanitized[key] = stripUnsupportedOpportunityClaims(stripUuids(value)) || 'A suggested connection.';
1382
+ }
1383
+ }
1384
+ const narratorChip = card.narratorChip;
1385
+ if (narratorChip && typeof narratorChip === 'object' && !Array.isArray(narratorChip)) {
1386
+ const narrator = narratorChip;
1387
+ if (typeof narrator.text === 'string') {
1388
+ sanitized.narratorChip = {
1389
+ ...narrator,
1390
+ text: stripUnsupportedOpportunityClaims(stripUuids(narrator.text)) || 'A potential connection worth exploring.',
1391
+ };
1392
+ }
1393
+ }
1394
+ return sanitized;
1395
+ }
1396
+ /**
1397
+ * Format opportunity cards into the "opportunities" portion of a tool response.
1398
+ *
1399
+ * Web chat (`isMcp=false`): emits ```opportunity``` code fences with an
1400
+ * "include EXACTLY as-is" directive so the frontend card renderer can parse
1401
+ * and render interactive cards.
1402
+ *
1403
+ * MCP (`isMcp=true`): emits prose (name, reason, status, appUrl and profileUrl
1404
+ * when present, feedCategory when present) and includes `opportunityId` for
1405
+ * every card so the agent can act via the tools. The trailing instruction
1406
+ * reminds the agent to synthesize in natural language, to surface the `appUrl`
1407
+ * verbatim as the one link that opens the card, and to fabricate no other URL.
1408
+ * MCP clients have no card renderer, so code fences would surface as raw JSON
1409
+ * to end users.
1410
+ */
1411
+ export function buildOpportunityPresentation(inputCards, opts) {
1412
+ const cards = inputCards.map(sanitizeOpportunityCardProse);
1413
+ if (cards.length === 0)
1414
+ return opts.leadIn;
1415
+ if (opts.isMcp) {
1416
+ const prose = cards
1417
+ .map((card, i) => {
1418
+ const lines = [`${i + 1}. ${card.name ?? "Unknown"}`];
1419
+ if (opts.includeDigestMarkers) {
1420
+ const markerId = String(card.opportunityId).replace(/[\s>]/g, "");
1421
+ if (markerId)
1422
+ lines.push(` <!-- digest-opportunity:id=${markerId} -->`);
1423
+ }
1424
+ if (opts.includeDigestMarkers && card.digestSummary) {
1425
+ lines.push(` ${card.digestSummary}`);
1426
+ }
1427
+ else if (card.mainText) {
1428
+ lines.push(` ${card.mainText}`);
1429
+ }
1430
+ if (card.status)
1431
+ lines.push(` status: ${card.status}`);
1432
+ if (card.appUrl)
1433
+ lines.push(` appUrl: ${card.appUrl}`);
1434
+ if (card.profileUrl)
1435
+ lines.push(` profileUrl: ${card.profileUrl}`);
1436
+ if (opts.includeDigestMarkers && card.negotiationUrl)
1437
+ lines.push(` negotiationUrl: ${card.negotiationUrl}`);
1438
+ if (card.feedCategory)
1439
+ lines.push(` feedCategory: ${card.feedCategory}`);
1440
+ if (opts.includeDigestMarkers && card.score != null)
1441
+ lines.push(` confidence: ${Math.round(card.score * 100)}`);
1442
+ if (opts.includeDigestMarkers && card.redelivery)
1443
+ lines.push(` redelivery: true`);
1444
+ lines.push(` opportunityId: ${card.opportunityId}`);
1445
+ return lines.join("\n");
1446
+ })
1447
+ .join("\n\n");
1448
+ const idInstructions = `Use opportunityId values only when calling update_opportunity (send/accept/reject) or confirm_opportunity_delivery.`;
1449
+ return (`${opts.leadIn}\n\n${prose}\n\n` +
1450
+ `Summarize these for the user in natural prose — mention first names and a brief match reason per connection. ` +
1451
+ `For each card that has a profileUrl, link the person's name to it. Some cards may have no URL — render those as plain text and never fabricate URLs for them. ` +
1452
+ `For each card that has an appUrl, show that link so the user can open the opportunity: it opens the card in the Index app when installed, and an Index web page otherwise. Show only an appUrl a tool returned — never assemble one from an opportunityId. ` +
1453
+ `No link accepts on the user's behalf: accepting happens in the Index app (or via update_opportunity) — never invent an accept URL. ` +
1454
+ `Do NOT print raw JSON, field labels, or opportunityIds. ` +
1455
+ `${idInstructions}`);
1456
+ }
1457
+ const label = opts.label ?? (cards.length === 1 ? "opportunity" : "opportunities");
1458
+ const blocks = cards
1459
+ .map((card) => CODE_FENCE + "opportunity\n" + sanitizeJsonForCodeFence(JSON.stringify(card)) + "\n" + CODE_FENCE)
1460
+ .join("\n\n");
1461
+ return (`${opts.leadIn} IMPORTANT: Include the following ${CODE_FENCE}${label} code blocks EXACTLY as-is in your response (they render as interactive cards):\n\n${blocks}`);
1462
+ }