@indexnetwork/protocol 65.0.0 → 66.0.0-rc.609.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 (26) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/IMPLEMENTATION.md +11 -12
  3. package/STABILITY.md +0 -1
  4. package/dist/index.d.ts +5 -7
  5. package/dist/index.js +3 -6
  6. package/dist/internal/opportunities/opportunity.cards.d.ts +78 -0
  7. package/dist/internal/opportunities/opportunity.cards.js +165 -0
  8. package/dist/internal/opportunities/opportunity.graph.modes.d.ts +0 -1
  9. package/dist/internal/opportunities/opportunity.graph.modes.js +0 -5
  10. package/dist/internal/opportunities/opportunity.labels.d.ts +0 -2
  11. package/dist/internal/opportunities/opportunity.labels.js +0 -2
  12. package/dist/internal/opportunities/opportunity.presentation.d.ts +21 -185
  13. package/dist/internal/opportunities/opportunity.presentation.js +35 -651
  14. package/dist/internal/opportunities/opportunity.utils.d.ts +0 -42
  15. package/dist/internal/opportunities/opportunity.utils.js +0 -102
  16. package/dist/platform/database/capabilities.d.ts +2 -2
  17. package/dist/platform/database/entities.d.ts +1 -1
  18. package/package.json +1 -1
  19. package/dist/internal/opportunities/opportunity.feed-selection.d.ts +0 -24
  20. package/dist/internal/opportunities/opportunity.feed-selection.js +0 -40
  21. package/dist/internal/opportunities/radar/radar.graph.d.ts +0 -230
  22. package/dist/internal/opportunities/radar/radar.graph.js +0 -537
  23. package/dist/internal/opportunities/radar/radar.state.d.ts +0 -88
  24. package/dist/internal/opportunities/radar/radar.state.js +0 -87
  25. package/dist/internal/shared/utils/claim-safety.d.ts +0 -24
  26. package/dist/internal/shared/utils/claim-safety.js +0 -88
@@ -2,16 +2,12 @@
2
2
  * The opportunity presentation cluster.
3
3
  *
4
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.
5
+ * reads: the cache-key builders and the LLM presenter itself. All user-facing
6
+ * copy comes from the presenter; there is no deterministic fallback.
9
7
  *
10
8
  * 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)
9
+ * 1. Presentation cache keys
10
+ * 2. OpportunityPresenter (LLM card and chat copy)
15
11
  */
16
12
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
17
13
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
@@ -22,548 +18,22 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
22
18
  var __metadata = (this && this.__metadata) || function (k, v) {
23
19
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
24
20
  };
25
- import { MINIMAL_MAIN_TEXT_MAX_CHARS } from "./opportunity.labels.js";
26
- import { stripUnsupportedOpportunityClaims } from "../shared/utils/claim-safety.js";
27
21
  import { HumanMessage, SystemMessage } from "@langchain/core/messages";
28
22
  import { z } from "zod";
29
23
  import { Timed } from "../shared/observability/performance.js";
30
- import { protocolLogger } from "../shared/observability/protocol.logger.js";
31
24
  import { createStructuredModel } from "../shared/agent/model.config.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, format) {
37
- const myActor = opp.actors.find((a) => a.userId === viewerId);
38
- if (!myActor) {
39
- throw new Error('Viewer is not an actor in this opportunity');
40
- }
41
- const otherName = otherPartyInfo.name;
42
- const safeReasoning = stripUnsupportedOpportunityClaims(stripUuids(opp.interpretation.reasoning)) ||
43
- 'A promising connection.';
44
- let title;
45
- let description;
46
- let descriptionIsReasoning = false;
47
- switch (myActor.role) {
48
- case 'agent':
49
- title = `You can help ${otherName}`;
50
- description = `Based on your expertise, ${otherName} might benefit from connecting with you.`;
51
- break;
52
- case 'patient':
53
- title = `${otherName} might be able to help you`;
54
- description = `${otherName} has skills that align with what you're looking for.`;
55
- break;
56
- case 'peer':
57
- title = `Potential collaboration with ${otherName}`;
58
- description = `You and ${otherName} have complementary interests.`;
59
- break;
60
- case 'mentee':
61
- title = `${otherName} could mentor you`;
62
- description = `${otherName} has experience that could help guide your journey.`;
63
- break;
64
- case 'mentor':
65
- title = `${otherName} is looking for guidance`;
66
- description = `Your expertise could help ${otherName} on their path.`;
67
- break;
68
- case 'founder':
69
- title = `${otherName} might be interested in your venture`;
70
- description = `${otherName}'s investment focus aligns with what you're building.`;
71
- break;
72
- case 'investor':
73
- title = `${otherName} is building something interesting`;
74
- description = `${otherName}'s venture might fit your investment thesis.`;
75
- break;
76
- case 'party':
77
- default:
78
- title = `Opportunity with ${otherName}`;
79
- description = safeReasoning;
80
- descriptionIsReasoning = true;
81
- break;
82
- }
83
- if (!descriptionIsReasoning) {
84
- description += `\n\n${safeReasoning}`;
85
- }
86
- if (format === 'notification') {
87
- description =
88
- description.length > 100 ? description.slice(0, 97) + '...' : description;
89
- }
90
- return {
91
- title,
92
- description,
93
- callToAction: 'View Opportunity',
94
- };
95
- }
96
- /**
97
- * Strips UUID patterns from user-facing text to prevent internal ID leaks.
98
- */
99
- const UUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
100
- export function stripUuids(text) {
101
- return text
102
- .replace(/\(([^)]*)\)/g, (_match, inner) => {
103
- if (!UUID_PATTERN.test(inner)) {
104
- UUID_PATTERN.lastIndex = 0;
105
- return _match;
106
- }
107
- UUID_PATTERN.lastIndex = 0;
108
- const cleaned = inner
109
- .replace(UUID_PATTERN, '')
110
- .replace(/,\s*,/g, ',')
111
- .replace(/\b(?:from|and)\b/gi, '')
112
- .replace(/^[\s,]+|[\s,]+$/g, '');
113
- return cleaned ? `(${cleaned})` : '';
114
- })
115
- .replace(UUID_PATTERN, '')
116
- .replace(/\s{2,}/g, ' ')
117
- .trim();
118
- }
119
- /**
120
- * Truncate user-facing text to at most `maxChars` without cutting mid-word.
121
- *
122
- * Prefers a sentence boundary, then a word boundary, and only falls back to a
123
- * hard slice if no boundary exists within the limit. An ellipsis is appended
124
- * when the text is actually shortened. Used by presenter fallbacks so a degraded
125
- * card never shows a sentence chopped mid-word (e.g. "His focus on 'indiv").
126
- */
127
- export function truncateAtBoundary(text, maxChars) {
128
- const trimmed = text.trim();
129
- if (trimmed.length <= maxChars)
130
- return trimmed;
131
- const slice = trimmed.slice(0, maxChars);
132
- // Prefer ending on the last completed sentence within the limit.
133
- const lastSentence = Math.max(slice.lastIndexOf(". "), slice.lastIndexOf("! "), slice.lastIndexOf("? "));
134
- if (lastSentence >= maxChars * 0.5) {
135
- return slice.slice(0, lastSentence + 1).trim();
136
- }
137
- // Otherwise back off to the last whole word and add an ellipsis.
138
- const lastSpace = slice.lastIndexOf(" ");
139
- const body = lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
140
- return body.replace(/[\s,;:.!?'"-]+$/, "").trim() + "\u2026";
141
- }
142
- // Helper function
143
- function escapeRegex(s) {
144
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
145
- }
146
- /**
147
- * Viewer-centric text for opportunity cards.
148
- * The card is shown to the viewer (logged-in user) and should introduce the
149
- * counterpart, not describe the viewer to themselves.
150
- */
151
- /**
152
- * Splits text into sentences using (?<=[.!?])\s+ (period/exclamation/question followed by whitespace).
153
- * Note: splits after any such punctuation, including abbreviations like "Dr." or "e.g.".
154
- */
155
- function splitSentences(text) {
156
- const trimmed = text.trim();
157
- if (!trimmed)
158
- return [];
159
- return trimmed
160
- .split(/(?<=[.!?])\s+/)
161
- .map((s) => s.trim())
162
- .filter(Boolean);
163
- }
164
- /**
165
- * Returns viewer-centric main text for an opportunity card.
166
- * Prefers the part of the reasoning that describes the counterpart (the person
167
- * on the card), so the viewer sees an introduction to the counterpart rather
168
- * than a description of themselves.
169
- *
170
- * @param reasoning - Raw interpretation.reasoning (may describe both parties).
171
- * @param counterpartName - Display name of the suggested connection (e.g. "Alex Chen").
172
- * @param maxChars - Max length of returned string (default MINIMAL_MAIN_TEXT_MAX_CHARS).
173
- * @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.
174
- * @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.
175
- */
176
- export function viewerCentricCardSummary(reasoning, counterpartName, maxChars = MINIMAL_MAIN_TEXT_MAX_CHARS, viewerName) {
177
- const raw = stripUnsupportedOpportunityClaims(stripUuids(reasoning));
178
- if (!raw)
179
- return "A suggested connection.";
180
- const name = counterpartName.trim();
181
- if (!name) {
182
- let out = raw.length <= maxChars ? raw : raw.slice(0, maxChars) + "...";
183
- out = replaceViewerNameWithYou(out, viewerName);
184
- return out;
185
- }
186
- const sentences = splitSentences(raw);
187
- const nameLower = name.toLowerCase();
188
- const firstWordOfName = name.split(/\s+/)[0]?.toLowerCase();
189
- const hasCounterpartName = (s) => s.toLowerCase().includes(nameLower) ||
190
- (firstWordOfName && firstWordOfName.length > 1 && s.toLowerCase().includes(firstWordOfName));
191
- const viewer = viewerName?.trim().toLowerCase();
192
- const viewerFirstWord = viewerName?.trim().split(/\s+/)[0]?.toLowerCase();
193
- const startsWithViewer = (s) => {
194
- if (!viewer)
195
- return false;
196
- const sl = s.toLowerCase();
197
- return sl.startsWith(viewer) ||
198
- (viewerFirstWord && viewerFirstWord.length > 1 && sl.startsWith(viewerFirstWord));
199
- };
200
- // When viewerName is provided, prefer sentences that mention the counterpart
201
- // but do NOT start with the viewer's name.
202
- if (viewer) {
203
- // First pass: find a sentence that mentions counterpart and doesn't start with viewer
204
- const cleanIdx = sentences.findIndex((s) => hasCounterpartName(s) && !startsWithViewer(s));
205
- if (cleanIdx !== -1) {
206
- const result = sentences.slice(cleanIdx).join(" ").trim();
207
- let out = result.length <= maxChars ? result : result.slice(0, maxChars) + "...";
208
- out = replaceViewerNameWithYou(out, viewerName, [name]);
209
- return out;
210
- }
211
- // Second pass: sentence mentions counterpart but starts with viewer (compound sentence).
212
- // Try to extract the counterpart portion after the counterpart's name.
213
- const compoundIdx = sentences.findIndex((s) => hasCounterpartName(s) && startsWithViewer(s));
214
- if (compoundIdx !== -1) {
215
- const sentence = sentences[compoundIdx];
216
- // Find where the counterpart name appears and extract from there
217
- // Use case-insensitive Unicode-aware regex so the index is correct
218
- // even when toLowerCase() changes string length (e.g. Turkish İ→i, German ß→ss).
219
- const cpMatch = sentence.match(new RegExp(escapeRegex(name), "iu"));
220
- const cpIdx = cpMatch?.index ?? -1;
221
- if (cpIdx > 0) {
222
- const extracted = sentence.slice(cpIdx).trim();
223
- const rest = sentences.slice(compoundIdx + 1).join(" ").trim();
224
- const result = rest ? `${extracted} ${rest}` : extracted;
225
- let out = result.length <= maxChars ? result : result.slice(0, maxChars) + "...";
226
- out = replaceViewerNameWithYou(out, viewerName, [name]);
227
- return out;
228
- }
229
- }
230
- }
231
- // Fallback: original logic without viewer awareness
232
- const idx = sentences.findIndex(hasCounterpartName);
233
- if (idx === -1) {
234
- let out = raw.length <= maxChars ? raw : raw.slice(0, maxChars) + "...";
235
- out = replaceViewerNameWithYou(out, viewerName, [name]);
236
- return out;
237
- }
238
- const fromCounterpart = sentences.slice(idx).join(" ").trim();
239
- let out = fromCounterpart.length <= maxChars
240
- ? fromCounterpart
241
- : fromCounterpart.slice(0, maxChars) + "...";
242
- out = replaceViewerNameWithYou(out, viewerName, [name]);
243
- return out;
244
- }
245
- /** Max length for narrator chip text (matches LLM presenter schema). */
246
- const NARRATOR_MAX_CHARS = 80;
247
- const FALLBACK_REMARK = "A potential connection worth exploring.";
248
- /**
249
- * Generates a short narrator remark from opportunity reasoning for the narrator chip.
250
- * Used by the minimal (no-LLM) card path so each card gets a unique remark
251
- * instead of the same static text.
252
- *
253
- * Extracts domain keywords (e.g. "AI", "design", "machine learning") from the
254
- * reasoning and frames them in a short template like "Shared interest in AI and design."
255
- *
256
- * This is a regex-based heuristic — an alternative is OpportunityPresenter.presentCard()
257
- * which generates narratorRemark via LLM with much higher quality.
258
- *
259
- * @param reasoning - Raw interpretation.reasoning text.
260
- * @param counterpartName - Display name of the counterpart (stripped from output).
261
- * @param viewerName - Optional display name of the viewer (stripped from output).
262
- * @returns A short remark (max ~80 chars) suitable for the narrator chip. Never truncated with "...".
263
- */
264
- export function narratorRemarkFromReasoning(reasoning, counterpartName, viewerName) {
265
- const raw = stripUnsupportedOpportunityClaims(stripUuids(reasoning)).trim();
266
- if (!raw)
267
- return FALLBACK_REMARK;
268
- // Strip all person names from the text so we work only with topics.
269
- let cleaned = raw;
270
- for (const name of [counterpartName, viewerName]) {
271
- if (!name?.trim())
272
- continue;
273
- const full = name.trim();
274
- cleaned = cleaned.replace(new RegExp(escapeRegex(full), "gi"), "").trim();
275
- const first = full.split(/\s+/)[0];
276
- if (first && first.length > 1) {
277
- cleaned = cleaned.replace(new RegExp(`\\b${escapeRegex(first)}\\b`, "gi"), "").trim();
278
- }
279
- }
280
- // Extract domain/topic noun phrases from the cleaned text.
281
- // Match multi-word capitalized phrases (e.g. "AI operations toolkit") and
282
- // known domain terms.
283
- const domainTerms = extractDomainTerms(cleaned);
284
- if (domainTerms.length > 0) {
285
- // Build "Shared interest in X and Y." or "Overlap in X, Y, and Z."
286
- const prefixes = [
287
- "Shared interest in",
288
- "Overlap in",
289
- "Common ground in",
290
- "Aligned on",
291
- "Mutual interest in",
292
- ];
293
- // Pick prefix deterministically based on first term's char code
294
- const prefixIdx = domainTerms[0].charCodeAt(0) % prefixes.length;
295
- const prefix = prefixes[prefixIdx];
296
- const joined = joinTerms(domainTerms, NARRATOR_MAX_CHARS - prefix.length - 2); // -2 for " " and "."
297
- const remark = `${prefix} ${joined}.`;
298
- if (remark.length <= NARRATOR_MAX_CHARS)
299
- return remark;
300
- }
301
- // Fallback: try to extract a short relationship phrase
302
- 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);
303
- if (relationshipMatch) {
304
- const phrase = relationshipMatch[0];
305
- const remark = `Spotted ${phrase.toLowerCase()}.`;
306
- if (remark.length <= NARRATOR_MAX_CHARS)
307
- return remark;
308
- }
309
- return FALLBACK_REMARK;
310
- }
311
- /**
312
- * Extracts domain/topic terms from text by matching known patterns:
313
- * - Acronyms (AI, ML, UX, API)
314
- * - Multi-word domain phrases (machine learning, game development)
315
- * - Capitalized proper nouns that look like topics
316
- */
317
- function extractDomainTerms(text) {
318
- const seen = new Set();
319
- const terms = [];
320
- // Known domain phrases (order matters — longer first)
321
- const knownPhrases = [
322
- /\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,
323
- /\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,
324
- ];
325
- for (const pattern of knownPhrases) {
326
- for (const match of text.matchAll(pattern)) {
327
- const term = match[1] ?? match[0];
328
- const key = term.toLowerCase();
329
- if (!seen.has(key)) {
330
- seen.add(key);
331
- // Preserve case for short acronyms/proper nouns; lowercase multi-word phrases
332
- if (term.length <= 5 && /^[A-Z]/.test(term)) {
333
- terms.push(term); // Keep React, AI, ML, etc. as-is
334
- }
335
- else {
336
- terms.push(key);
337
- }
338
- }
339
- }
340
- }
341
- // If no known phrases found, look for capitalized multi-word phrases
342
- // that look like explicit topic references (e.g. "Visual Art", "Smart Contracts").
343
- // Only accept capitalized words to avoid grabbing meta-language from evaluator reasoning
344
- // (e.g. "discoverer", "explicitly", "states" which are about the matching process, not topics).
345
- if (terms.length === 0) {
346
- // Multi-word capitalized phrases first (e.g. "Visual Art", "Creative Writing")
347
- const multiWordPattern = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\b/g;
348
- for (const match of text.matchAll(multiWordPattern)) {
349
- const term = match[1];
350
- const key = term.toLowerCase();
351
- if (!seen.has(key)) {
352
- seen.add(key);
353
- terms.push(key);
354
- if (terms.length >= 3)
355
- break;
356
- }
357
- }
358
- // Single capitalized words as last resort (skip common sentence-starters and meta-words)
359
- if (terms.length === 0) {
360
- const skipCapitalized = new Set([
361
- // Articles / conjunctions / prepositions (capitalized at sentence start)
362
- "the", "and", "but", "for", "from", "with", "without", "between",
363
- "into", "about", "after", "before", "over", "under", "through",
364
- // Common sentence starters / pronouns / determiners
365
- "both", "their", "they", "this", "that", "these", "those",
366
- "here", "there", "would", "could", "should", "also", "very",
367
- "one", "another", "other", "each", "some", "many", "most",
368
- "such", "clear", "high", "good", "well", "just", "even",
369
- // Generic matching/relationship language
370
- "strong", "match", "based", "making", "looking", "seeking",
371
- "connection", "relationship", "opportunity", "overlap",
372
- "complementary", "potential", "interested", "collaborate",
373
- // Evaluator meta-language (about the matching process, not topics)
374
- "intent", "intents", "profile", "user", "users", "person",
375
- "discoverer", "explicitly", "states", "expressed", "mentioned",
376
- "indicates", "suggests", "demonstrates", "describes", "involves",
377
- "inference", "preparatory", "sincerity", "evaluator", "classifier",
378
- "semantic", "pragmatic", "verification", "reconciliation",
379
- "assertive", "commissive", "directive", "illocutionary",
380
- "felicity", "utterance", "detected", "analysis", "confirmed",
381
- "genuine", "conditions", "determined",
382
- // Discourse markers
383
- "particularly", "specifically", "especially", "primarily",
384
- "overall", "furthermore", "however", "therefore", "moreover",
385
- ]);
386
- const capWords = text.match(/\b[A-Z][a-z]{2,}\b/g) ?? [];
387
- for (const w of capWords) {
388
- const key = w.toLowerCase();
389
- if (!skipCapitalized.has(key) && !seen.has(key)) {
390
- seen.add(key);
391
- terms.push(key);
392
- if (terms.length >= 3)
393
- break;
394
- }
395
- }
396
- }
397
- }
398
- return terms.slice(0, 3); // Max 3 terms
399
- }
400
- /** Joins terms into "X, Y, and Z" form, dropping terms if too long. */
401
- function joinTerms(terms, maxLen) {
402
- if (terms.length === 1)
403
- return terms[0];
404
- // Try all terms first
405
- for (let count = terms.length; count >= 1; count--) {
406
- const subset = terms.slice(0, count);
407
- let joined;
408
- if (subset.length === 1) {
409
- joined = subset[0];
410
- }
411
- else if (subset.length === 2) {
412
- joined = `${subset[0]} and ${subset[1]}`;
413
- }
414
- else {
415
- joined = `${subset.slice(0, -1).join(", ")}, and ${subset[subset.length - 1]}`;
416
- }
417
- if (joined.length <= maxLen)
418
- return joined;
419
- }
420
- return terms[0].slice(0, maxLen);
421
- }
422
- /**
423
- * Replaces viewer's name with "you"/"your" so the card addresses the viewer in second person.
424
- * Applied to mainText when viewerName is provided.
425
- * @param otherNames - Other actor names in the card; first-name replacement is
426
- * skipped when the viewer's first name matches any other actor's first name.
427
- */
428
- function replaceViewerNameWithYou(text, viewerName, otherNames) {
429
- if (!viewerName?.trim())
430
- return text;
431
- const full = viewerName.trim();
432
- const first = full.split(/\s+/)[0];
433
- let out = text;
434
- // Possessive: "Yankı's" → "your", "Yankı Ekin Yüksel's" → "your"
435
- out = out.replace(new RegExp(`\\b${escapeRegex(full)}'s\\b`, "gi"), "your");
436
- const otherFirstNames = (otherNames ?? [])
437
- .map(n => n.trim().split(/\s+/)[0]?.toLowerCase())
438
- .filter(Boolean);
439
- const firstNameCollides = first && otherFirstNames.includes(first.toLowerCase());
440
- if (first && first.length > 1 && !firstNameCollides) {
441
- out = out.replace(new RegExp(`\\b${escapeRegex(first)}'s\\b`, "gi"), "your");
442
- }
443
- // Standalone: full name then first name so we don't break "Yankı Ekin Yüksel"
444
- out = out.replace(new RegExp(`\\b${escapeRegex(full)}\\b`, "gi"), "you");
445
- if (first && first.length > 1 && !firstNameCollides) {
446
- out = out.replace(new RegExp(`\\b${escapeRegex(first)}\\b`, "gi"), "you");
447
- }
448
- return out;
449
- }
450
25
  // ──────────────────────────────────────────────────────────────────────
451
- // ── 2. Presentation cache keys ──
26
+ // ── 1. Presentation cache keys ──
452
27
  // ──────────────────────────────────────────────────────────────────────
453
28
  /** Cache namespace for opportunity presentation copy. Bump to invalidate copy safety changes. */
454
29
  export const OPPORTUNITY_PRESENTATION_CACHE_VERSION = "v2";
455
- export function buildRadarCardPresentationCacheKey(opportunityId, status, viewerId, focusedViewerIntentId) {
30
+ export function buildOpportunityCardCacheKey(opportunityId, status, viewerId, focusedViewerIntentId) {
456
31
  const scope = focusedViewerIntentId ? `:intent:${focusedViewerIntentId}` : "";
457
- return `radar:${OPPORTUNITY_PRESENTATION_CACHE_VERSION}:card:${opportunityId}:${status}:${viewerId}${scope}`;
32
+ return `card:${OPPORTUNITY_PRESENTATION_CACHE_VERSION}:${opportunityId}:${status}:${viewerId}${scope}`;
458
33
  }
459
34
  export function buildApiChatCardPresentationCacheKey(opportunityId, viewerId) {
460
35
  return `chat:${OPPORTUNITY_PRESENTATION_CACHE_VERSION}:card:${opportunityId}:${viewerId}`;
461
36
  }
462
- // ──────────────────────────────────────────────────────────────────────
463
- // ── 3. Safe-presentation pipeline ──
464
- // ──────────────────────────────────────────────────────────────────────
465
- /**
466
- * Shared safe-presentation primitive for all user-facing opportunity surfaces.
467
- *
468
- * Historically every surface (radar, list/discover cards, minimal chat
469
- * cards, notification emails/Telegram, chat context, delivery cards) invented
470
- * its own fallback chain for the case where genuine LLM presenter output is
471
- * unavailable — some sliced raw `interpretation.reasoning` with no
472
- * sanitization at all. This module is the single standard:
473
- *
474
- * raw reasoning
475
- * → whitespace-normalize
476
- * → viewer-centric rewrite (incl. UUID stripping)
477
- * → boundary-aware truncation
478
- * → per-surface empty-text default
479
- *
480
- * Surfaces choose *policy* (send a sanitized fallback vs skip entirely) via
481
- * `allowFallback`; they no longer choose (or forget) sanitization steps.
482
- *
483
- * See `packages/protocol/s./opportunity/AGENTS.md` for the review checklist this
484
- * module exists to satisfy.
485
- */
486
- /** Default max length for fallback summaries (matches presenter internal fallback). */
487
- export const SAFE_FALLBACK_MAX_CHARS = 300;
488
- /** Default copy when no reasoning text is available at all. */
489
- export const DEFAULT_EMPTY_FALLBACK_TEXT = "A promising connection.";
490
- /** Default headline for fallback presentations (matches presenter internal fallback). */
491
- export const DEFAULT_FALLBACK_HEADLINE = "A promising connection";
492
- /** Default CTA for fallback presentations (matches presenter internal fallback). */
493
- export const DEFAULT_FALLBACK_ACTION = "Take a look and decide whether to reach out.";
494
- /**
495
- * Produce safe user-facing fallback copy from raw match reasoning.
496
- *
497
- * This is the ONE sanitization standard: UUID stripping,
498
- * stripping, and viewer-centric rewrite (via {@link viewerCentricCardSummary}),
499
- * followed by whitespace normalization and boundary-aware truncation (via
500
- * {@link truncateAtBoundary}). Never returns raw reasoning verbatim beyond
501
- * these guarantees, and never returns an empty string.
502
- *
503
- * @param rawReasoning - Raw `interpretation.reasoning` / `matchReason` text (may be null/undefined).
504
- * @param opts - Per-surface knobs (names for rewrite, max length, empty-text copy).
505
- */
506
- export function safeFallbackSummary(rawReasoning, opts = {}) {
507
- const emptyText = opts.emptyText ?? DEFAULT_EMPTY_FALLBACK_TEXT;
508
- const maxChars = opts.maxChars ?? SAFE_FALLBACK_MAX_CHARS;
509
- const normalized = (rawReasoning ?? "").replace(/\s+/g, " ").trim();
510
- if (!normalized)
511
- return emptyText;
512
- const claimSafeInput = stripUnsupportedOpportunityClaims(normalized);
513
- if (!claimSafeInput)
514
- return emptyText;
515
- // viewerCentricCardSummary handles UUID stripping,
516
- // stripping, and the viewer-centric rewrite. Pass Infinity so truncation is
517
- // handled by boundary-aware logic below instead of a mid-word hard slice.
518
- const rewritten = viewerCentricCardSummary(claimSafeInput, opts.counterpartName ?? "", Number.POSITIVE_INFINITY, opts.viewerName);
519
- // Claim validation intentionally runs after viewer-centric rewriting: rewrite
520
- // heuristics may select or join different source sentences, and the final
521
- // user-facing sentence set is what must be safe.
522
- const claimSafe = stripUnsupportedOpportunityClaims(rewritten);
523
- const truncated = truncateAtBoundary(claimSafe, maxChars);
524
- return truncated || emptyText;
525
- }
526
- /**
527
- * Resolve the safe user-facing presentation for an opportunity, or signal skip.
528
- *
529
- * Resolution order:
530
- * 1. Genuine presenter output (`homeCardPresentation` present, non-empty, and
531
- * NOT tagged `isFallback` by the presenter) — claim-validated before return.
532
- * 2. Otherwise, if `allowFallback` (default true): sanitized fallback copy
533
- * built from `matchReason` / `interpretation.reasoning` via
534
- * {@link safeFallbackSummary}.
535
- * 3. Otherwise `null` — the surface must skip this opportunity.
536
- *
537
- * Raw `interpretation.reasoning` / `matchReason` never reaches the caller
538
- * unsanitized through this function.
539
- */
540
- export function getSafePresentationOrSkip(source, opts = {}) {
541
- const candidate = source.homeCardPresentation;
542
- if (candidate?.personalizedSummary?.trim() && !candidate.isFallback) {
543
- const summary = stripUnsupportedOpportunityClaims(candidate.personalizedSummary);
544
- if (summary) {
545
- return {
546
- headline: stripUnsupportedOpportunityClaims(candidate.headline) ||
547
- DEFAULT_FALLBACK_HEADLINE,
548
- summary,
549
- suggestedAction: stripUnsupportedOpportunityClaims(candidate.suggestedAction) ||
550
- DEFAULT_FALLBACK_ACTION,
551
- isFallback: false,
552
- };
553
- }
554
- }
555
- if (opts.allowFallback === false)
556
- return null;
557
- const rawReasoning = source.matchReason ?? source.interpretation?.reasoning ?? "";
558
- return {
559
- headline: DEFAULT_FALLBACK_HEADLINE,
560
- summary: safeFallbackSummary(rawReasoning, opts),
561
- suggestedAction: DEFAULT_FALLBACK_ACTION,
562
- isFallback: true,
563
- };
564
- }
565
- const presentLog = protocolLogger("OpportunityPresenter:present");
566
- const presentCardLog = protocolLogger("OpportunityPresenter:presentCard");
567
37
  const LLM_TIMEOUT_MS = 20000;
568
38
  const GREETING_DESCRIPTION = "A 2-4 sentence first-person message the viewer could send to the counterpart, in the viewer's voice, referencing what they have in common. Plain prose only — no markdown, no greeting prefix like 'Hey {Name},'. Example body: 'Saw we're both working on regenerative coordination tooling — your post on consent flows resonated. Would love to compare notes if you have time this week.'";
569
39
  // ──────────────────────────────────────────────────────────────
@@ -622,6 +92,8 @@ Rules:
622
92
  5. If possible, avoid repeating "opportunity" in both headline and summary. Prefer alternatives like "connection", "thought partner", "mutual fit", "valuable conversation", or "peer".
623
93
  6. Prefer first names in user-facing copy. Do not repeatedly use full names unless needed to disambiguate.
624
94
  7. Network assignment, network title/type, and network/event metadata are retrieval context only. They are NEVER proof that a person attended or will attend, belongs to a group, resides in a place, knows anyone from the network, or shared a session, time, place, or location with anyone. Do not make co-attendance, membership, residence, shared-session, or same-place/same-time claims from network co-membership.
95
+ 8. Match reasoning may describe the viewer in third person; always write from the viewer's perspective.
96
+ 9. Never output internal IDs.
625
97
 
626
98
 
627
99
  **Role-Specific Presentation:**
@@ -667,6 +139,8 @@ Rules:
667
139
  - Vary wording for the match itself. Do not repeat "opportunity" across headline, summary, and narratorRemark when alternatives fit.
668
140
  - Prefer first names in user-facing copy. Avoid repeated full names unless disambiguation is necessary.
669
141
  - Network assignment, network title/type, and network/event metadata are retrieval context only. They are NEVER proof that a person attended or will attend, belongs to a group, resides in a place, knows anyone from the network, or shared a session, time, place, or location with anyone. Do not make co-attendance, membership, residence, shared-session, or same-place/same-time claims from network co-membership.
142
+ - Match reasoning may describe the viewer in third person; always write from the viewer's perspective.
143
+ - Never output internal IDs.
670
144
  - If you cannot fit every detail, choose one clear reason and stop. Do not rely on downstream truncation.
671
145
 
672
146
  **Negotiation-grounded explanations (ONLY when NEGOTIATION CONTEXT is provided):**
@@ -678,16 +152,6 @@ When NEGOTIATION CONTEXT is provided, this opportunity passed through an agent-t
678
152
 
679
153
  `;
680
154
  // ──────────────────────────────────────────────────────────────
681
- // DETERMINISTIC OUTPUT VALIDATION
682
- // ──────────────────────────────────────────────────────────────
683
- function sanitizePresenterField(value, fallback, allowEmpty = fallback === "") {
684
- const cleaned = stripUnsupportedOpportunityClaims(stripUuids(value));
685
- if (cleaned || allowEmpty) {
686
- return { value: cleaned, usedFallback: false };
687
- }
688
- return { value: fallback, usedFallback: true };
689
- }
690
- // ──────────────────────────────────────────────────────────────
691
155
  // CLASS
692
156
  // ──────────────────────────────────────────────────────────────
693
157
  export class OpportunityPresenter {
@@ -722,6 +186,11 @@ export class OpportunityPresenter {
722
186
  }
723
187
  /**
724
188
  * Generate personalized presentation for a single opportunity.
189
+ *
190
+ * @param input - Pre-assembled presenter context.
191
+ * @param options - Optional abort signal.
192
+ * @returns The LLM-generated presentation.
193
+ * @throws When the LLM call fails, times out, or returns invalid output.
725
194
  */
726
195
  async present(input, options = {}) {
727
196
  const humanContent = `
@@ -741,47 +210,12 @@ Viewer's role in this opportunity: ${input.viewerRole}
741
210
 
742
211
  Produce headline, personalizedSummary (2-3 sentences in "you" language), suggestedAction, and greeting.
743
212
  `;
744
- try {
745
- const messages = [
746
- new SystemMessage(systemPrompt),
747
- new HumanMessage(humanContent),
748
- ];
749
- const result = await this.invokeWithTimeout(this.model, messages, options.signal);
750
- const parsed = responseFormat.parse(result);
751
- const headline = sanitizePresenterField(parsed.presentation.headline, DEFAULT_FALLBACK_HEADLINE);
752
- const summary = sanitizePresenterField(parsed.presentation.personalizedSummary, DEFAULT_EMPTY_FALLBACK_TEXT);
753
- const action = sanitizePresenterField(parsed.presentation.suggestedAction, DEFAULT_FALLBACK_ACTION);
754
- const greeting = sanitizePresenterField(parsed.presentation.greeting, "");
755
- const usedFallback = headline.usedFallback || summary.usedFallback || action.usedFallback || greeting.usedFallback;
756
- return {
757
- headline: headline.value,
758
- personalizedSummary: summary.value,
759
- suggestedAction: action.value,
760
- greeting: greeting.value,
761
- ...(usedFallback ? { isFallback: true, fallbackReason: "sanitization" } : {}),
762
- };
763
- }
764
- catch (e) {
765
- if (options.signal?.aborted)
766
- throw e;
767
- const message = e instanceof Error ? e.message : String(e);
768
- const timeoutReason = message.includes("timed out") ? message : undefined;
769
- presentLog.warn("LLM failed, returning fallback", {
770
- event: "presenter_fallback",
771
- presenter: "opportunity",
772
- reason: timeoutReason ? "timeout" : "parse_error",
773
- message,
774
- timeoutReason,
775
- });
776
- return {
777
- headline: DEFAULT_FALLBACK_HEADLINE,
778
- personalizedSummary: safeFallbackSummary(input.matchReasoning),
779
- suggestedAction: DEFAULT_FALLBACK_ACTION,
780
- greeting: "",
781
- isFallback: true,
782
- fallbackReason: timeoutReason ? "timeout" : "error",
783
- };
784
- }
213
+ const messages = [
214
+ new SystemMessage(systemPrompt),
215
+ new HumanMessage(humanContent),
216
+ ];
217
+ const result = await this.invokeWithTimeout(this.model, messages, options.signal);
218
+ return responseFormat.parse(result).presentation;
785
219
  }
786
220
  /**
787
221
  * Generate LLM-powered card content (headline, body, narrator remark, mutual-intent label).
@@ -790,6 +224,10 @@ Produce headline, personalizedSummary (2-3 sentences in "you" language), suggest
790
224
  * When `negotiationContext.status === 'negotiating'`, returns a templated
791
225
  * chip synchronously without invoking the LLM — the card just reflects
792
226
  * "negotiation in progress" at that point.
227
+ *
228
+ * @param input - Pre-assembled card presenter context.
229
+ * @returns The LLM-generated card copy.
230
+ * @throws When the LLM call fails, times out, or returns invalid output.
793
231
  */
794
232
  async presentCard(input) {
795
233
  if (input.negotiationContext?.status === 'negotiating') {
@@ -825,58 +263,12 @@ Opportunity status: ${input.opportunityStatus ?? "pending"}
825
263
 
826
264
  Produce headline, personalizedSummary, suggestedAction, narratorRemark, greeting, and mutualIntentsLabel.
827
265
  `;
828
- try {
829
- const messages = [
830
- new SystemMessage(homeCardSystemPrompt),
831
- new HumanMessage(humanContent),
832
- ];
833
- const result = await this.invokeWithTimeout(this.homeCardModel, messages);
834
- const parsed = homeCardResponseFormat.parse(result);
835
- if (/^0\s+(mutual|overlapping)\s+intent/i.test(parsed.presentation.mutualIntentsLabel)) {
836
- parsed.presentation.mutualIntentsLabel = "Shared interests";
837
- }
838
- const fields = {
839
- headline: sanitizePresenterField(parsed.presentation.headline, DEFAULT_FALLBACK_HEADLINE),
840
- personalizedSummary: sanitizePresenterField(parsed.presentation.personalizedSummary, DEFAULT_EMPTY_FALLBACK_TEXT),
841
- suggestedAction: sanitizePresenterField(parsed.presentation.suggestedAction, DEFAULT_FALLBACK_ACTION),
842
- narratorRemark: sanitizePresenterField(parsed.presentation.narratorRemark, "Worth a look."),
843
- mutualIntentsLabel: sanitizePresenterField(parsed.presentation.mutualIntentsLabel, "Shared interests"),
844
- greeting: sanitizePresenterField(parsed.presentation.greeting, ""),
845
- };
846
- const usedFallback = Object.values(fields).some((field) => field.usedFallback);
847
- return {
848
- headline: fields.headline.value,
849
- personalizedSummary: fields.personalizedSummary.value,
850
- suggestedAction: fields.suggestedAction.value,
851
- narratorRemark: fields.narratorRemark.value,
852
- mutualIntentsLabel: fields.mutualIntentsLabel.value,
853
- greeting: fields.greeting.value,
854
- ...(usedFallback ? { isFallback: true } : {}),
855
- };
856
- }
857
- catch (e) {
858
- const message = e instanceof Error ? e.message : String(e);
859
- const timeoutReason = message.includes("timed out") ? message : undefined;
860
- presentCardLog.warn("LLM failed, returning fallback", {
861
- event: "presenter_fallback",
862
- presenter: "home_card",
863
- reason: timeoutReason ? "timeout" : "parse_error",
864
- message,
865
- timeoutReason,
866
- });
867
- const fallbackSummary = safeFallbackSummary(input.matchReasoning);
868
- return {
869
- headline: "A promising connection",
870
- personalizedSummary: fallbackSummary,
871
- suggestedAction: "Take a look and decide whether to reach out.",
872
- narratorRemark: "Worth a look.",
873
- mutualIntentsLabel: input.mutualIntentCount != null && input.mutualIntentCount > 0
874
- ? `${input.mutualIntentCount} mutual intent${input.mutualIntentCount !== 1 ? "s" : ""}`
875
- : "Shared interests",
876
- greeting: "",
877
- isFallback: true,
878
- };
879
- }
266
+ const messages = [
267
+ new SystemMessage(homeCardSystemPrompt),
268
+ new HumanMessage(humanContent),
269
+ ];
270
+ const result = await this.invokeWithTimeout(this.homeCardModel, messages);
271
+ return homeCardResponseFormat.parse(result).presentation;
880
272
  }
881
273
  /**
882
274
  * Process multiple opportunities in parallel with bounded concurrency.
@@ -995,7 +387,7 @@ export function summarizeSignalsForPresenter(signals) {
995
387
  * Gather all context needed for the presenter from the database.
996
388
  * Fetches viewer profile, viewer intents, other party profile(s), and network in parallel.
997
389
  *
998
- * @param displayCounterpartUserId - When set (e.g. for a radar card), only this counterpart is included in otherPartyContext so the presenter writes about the person on the card.
390
+ * @param displayCounterpartUserId - When set (e.g. for an opportunity card), only this counterpart is included in otherPartyContext so the presenter writes about the person on the card.
999
391
  * @param focusedViewerIntentId - When set, include only that active intent in viewer context.
1000
392
  */
1001
393
  export async function gatherPresenterContext(database, opportunity, viewerId, displayCounterpartUserId, focusedViewerIntentId) {
@@ -1045,23 +437,15 @@ export async function gatherPresenterContext(database, opportunity, viewerId, di
1045
437
  otherParts.join("\n\n") || "Other party (details not available).";
1046
438
  }
1047
439
  const interp = opportunity.interpretation;
1048
- const signalsSummary = summarizeSignalsForPresenter(interp.signals);
1049
- const counterpartName = otherPartyIds.length === 1 && otherProfiles[0]
1050
- ? otherProfiles[0]?.identity?.name?.trim()
1051
- : undefined;
1052
- const viewerNameForFilter = viewerProfile?.identity?.name?.trim();
1053
- const matchReasoning = counterpartName && interp.reasoning
1054
- ? viewerCentricCardSummary(interp.reasoning, counterpartName, 400, viewerNameForFilter)
1055
- : stripUuids(interp.reasoning);
1056
440
  const result = {
1057
441
  viewerContext,
1058
442
  otherPartyContext,
1059
- matchReasoning,
443
+ matchReasoning: interp.reasoning,
1060
444
  category: interp.category ?? "connection",
1061
445
  confidence: typeof interp.confidence === "number"
1062
446
  ? interp.confidence
1063
447
  : parseFloat(String(interp.confidence ?? 0)) || 0,
1064
- signalsSummary,
448
+ signalsSummary: summarizeSignalsForPresenter(interp.signals),
1065
449
  networkName: networkRecord?.title ?? contextNetworkId ?? "",
1066
450
  viewerRole: myActor.role ?? "party",
1067
451
  };