@indexnetwork/protocol 4.3.4-rc.312.1 → 4.3.5-rc.313.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.
@@ -81,7 +81,7 @@ export declare function canUserSeeOpportunity(actors: Array<{
81
81
  * (1) `latent`, no introducer → all actors actionable
82
82
  * (2) `latent`, introducer `approved !== true` → introducer only
83
83
  * (3) `latent`, introducer `approved === true` → all non-introducer actors
84
- * (4) `pending` (any introducer config) → all non-introducer actors
84
+ * (4) `pending` (any introducer config) → non-introducer actors who have not acted
85
85
  * (5) `accepted`/`rejected`/`expired`/`stalled`/`draft`/`negotiating`
86
86
  * → never actionable
87
87
  *
@@ -94,6 +94,7 @@ export declare function isActionableForViewer(actors: Array<{
94
94
  userId: string;
95
95
  role: string;
96
96
  approved?: boolean;
97
+ actedAt?: string | null;
97
98
  }>, status: string, viewerId: string): boolean;
98
99
  /** Feed category for home composition. */
99
100
  export type FeedCategory = 'connection' | 'connector-flow' | 'expired';
@@ -1 +1 @@
1
- {"version":3,"file":"opportunity.utils.d.ts","sourceRoot":"/","sources":["opportunity/opportunity.utils.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"opportunity.utils.d.ts","sourceRoot":"/","sources":["opportunity/opportunity.utils.ts"],"names":[],"mappings":"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"}
@@ -122,7 +122,7 @@ export function canUserSeeOpportunity(actors, status, userId) {
122
122
  * (1) `latent`, no introducer → all actors actionable
123
123
  * (2) `latent`, introducer `approved !== true` → introducer only
124
124
  * (3) `latent`, introducer `approved === true` → all non-introducer actors
125
- * (4) `pending` (any introducer config) → all non-introducer actors
125
+ * (4) `pending` (any introducer config) → non-introducer actors who have not acted
126
126
  * (5) `accepted`/`rejected`/`expired`/`stalled`/`draft`/`negotiating`
127
127
  * → never actionable
128
128
  *
@@ -138,7 +138,7 @@ export function isActionableForViewer(actors, status, viewerId) {
138
138
  const introducer = actors.find((a) => a.role === 'introducer');
139
139
  const hasIntroducer = !!introducer;
140
140
  const introducerApproved = introducer?.approved === true;
141
- return viewerActors.some(({ role }) => {
141
+ return viewerActors.some(({ role, actedAt }) => {
142
142
  if (role === 'introducer') {
143
143
  // Rule 2: introducer sees own latent opp only while not yet approved.
144
144
  return status === 'latent' && !introducerApproved;
@@ -150,8 +150,10 @@ export function isActionableForViewer(actors, status, viewerId) {
150
150
  return !hasIntroducer || introducerApproved;
151
151
  }
152
152
  if (status === 'pending') {
153
- // Rule 4: visible to all non-introducer actors.
154
- return true;
153
+ // Rule 4: pending is actionable only for actors who have not already
154
+ // acted. Once an actor has `actedAt`, the opportunity is waiting on the
155
+ // counterparty and should not appear in that actor's home feed.
156
+ return !actedAt;
155
157
  }
156
158
  // Rule 5: never actionable at terminal or internal statuses.
157
159
  return false;
@@ -1 +1 @@
1
- {"version":3,"file":"opportunity.utils.js","sourceRoot":"/","sources":["opportunity/opportunity.utils.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Opportunity graph utilities: role derivation from corpus type.\n * Used by the opportunity graph to map lens corpus to opportunity actor roles.\n *\n * With lens-based HyDE, strategy selection is handled automatically by the\n * LensInferrer agent. This file provides corpus-to-role mapping for opportunity actors.\n */\n\nimport type { HydeTargetCorpus } from '../shared/hyde/lens.inferrer.js';\nimport { log } from '../shared/observability/log.js';\n\nconst logger = log.graph.from('SelectByComposition');\n\n/** Actor roles in the opportunity model (agent / patient / peer). */\nexport type OpportunityActorRole = 'agent' | 'patient' | 'peer';\n\n/** Result of mapping a corpus to source and candidate roles. */\nexport interface DerivedRoles {\n sourceRole: OpportunityActorRole;\n candidateRole: OpportunityActorRole;\n}\n\n/**\n * Derive actor roles from the corpus type of a lens match.\n *\n * When a candidate is found via:\n * - \"profiles\" corpus → found by who they are → candidate can help → agent\n * - \"intents\" corpus → found by what they need → candidate needs something → patient\n *\n * @param corpus - The target corpus that produced the match ('profiles' | 'intents')\n * @returns Roles for the source (intent owner) and the candidate (matched user/intent)\n */\nexport function deriveRolesFromCorpus(corpus: HydeTargetCorpus): DerivedRoles {\n switch (corpus) {\n case 'profiles':\n // Source seeks someone who can help → source is patient, candidate can help → agent\n return { sourceRole: 'patient', candidateRole: 'agent' };\n case 'intents':\n // Source offers or needs; candidate has complementary goal → source is agent, candidate is patient\n return { sourceRole: 'agent', candidateRole: 'patient' };\n case 'premises':\n // Premise matches are symmetric: two people whose self-descriptions align.\n // Unlike intents (directional roles), premises express stable identity truths.\n return { sourceRole: 'peer', candidateRole: 'peer' };\n default:\n return { sourceRole: 'peer', candidateRole: 'peer' };\n }\n}\n\n/**\n * Validates opportunity actors: if an opportunity has an introducer, it must have\n * one or two non-introducer actors (1 = 1:1 intro e.g. \"I want to connect with X\";\n * 2 = introducer connecting two others).\n *\n * Also rejects self-matches — the same person occupying both sides of a\n * connection. The discovery/persist pipeline trusts the LLM evaluator's actor\n * list, which can collapse onto a single user; downstream readers then garble\n * identity (e.g. a connect link/greeting rendered in one party's voice while the\n * card shows the viewer \"matched with themselves\"). Two degenerate shapes are\n * blocked here, at the single persist chokepoint:\n * - every userId-bearing non-introducer actor collapses to the same user,\n * e.g. `[X(agent), X(patient)]`\n * - an introducer who is also a participant (\"Amina introduced you to Amina\")\n * Only `userId`-bearing actors are checked; role-only actors (legacy/tests) pass.\n * Duplicate rows for one participant are allowed when at least one other distinct\n * participant is present (some callers model multiple intents as multiple actor rows).\n *\n * @param actors - Array of actors with at least a role and optional userId\n * @throws Error when the actor set is invalid\n */\nexport function validateOpportunityActors(actors: Array<{ userId?: string; role: string }>): void {\n const introducerCount = actors.filter((a) => a.role === 'introducer').length;\n const nonIntroducerCount = actors.filter((a) => a.role !== 'introducer').length;\n\n if (introducerCount > 0 && (nonIntroducerCount < 1 || nonIntroducerCount > 2)) {\n throw new Error(\n 'An opportunity with an introducer must have one or two other actors.'\n );\n }\n\n // Self-match guard. Compare only actors that carry a userId so role-only\n // shapes (used by some callers/tests) are unaffected.\n const introducerUserIds = new Set(\n actors.filter((a) => a.role === 'introducer' && a.userId).map((a) => a.userId as string),\n );\n const nonIntroducerUserIds = actors\n .filter((a) => a.role !== 'introducer' && a.userId)\n .map((a) => a.userId as string);\n\n for (const userId of nonIntroducerUserIds) {\n if (introducerUserIds.has(userId)) {\n throw new Error(\n 'An opportunity actor cannot be both the introducer and a participant (self-match).'\n );\n }\n }\n\n const uniqueNonIntroducerUserIds = new Set(nonIntroducerUserIds);\n if (nonIntroducerUserIds.length > 1 && uniqueNonIntroducerUserIds.size === 1) {\n throw new Error('An opportunity cannot match a user with themselves (duplicate participant).');\n }\n}\n\n/**\n * Read-level ACL: whether a user is an actor on the opportunity and may fetch\n * its details. Intentionally broader than `isActionableForViewer` — a user can\n * read an opportunity they are not currently expected to act on (e.g. an agent\n * viewing an accepted opportunity).\n *\n * The feed graph and debug controller chain both predicates: an opportunity only\n * reaches the home feed if it passes `canUserSeeOpportunity` first, then\n * `isActionableForViewer`. For `agent with introducer at pending`,\n * `canUserSeeOpportunity` returns false (read gate blocks it), so the opportunity\n * never surfaces even though `isActionableForViewer` Rule 4 would return true in\n * isolation. This is by design — the agent is not granted read access through the\n * home path until the introducer path completes (negotiation → accepted).\n *\n * Compact Visibility Rule (from lifecycle doc):\n * - Introducer or peer: always see.\n * - Patient or party: see if (status is not latent, or there is no introducer).\n * - Agent: see if (status is accepted/rejected/expired, or (status is not latent and there is no introducer)).\n */\nexport function canUserSeeOpportunity(\n actors: Array<{ userId: string; role: string }>,\n status: string,\n userId: string\n): boolean {\n const hasIntroducer = actors.some((a) => a.role === 'introducer');\n const userRoles = actors.filter((a) => a.userId === userId).map((a) => a.role);\n if (userRoles.length === 0) return false;\n\n return userRoles.some((role) => {\n if (role === 'introducer') return true;\n if (role === 'peer') return true;\n if (role === 'patient' || role === 'party')\n return status !== 'latent' || !hasIntroducer;\n if (role === 'agent')\n return (\n ['accepted', 'rejected', 'expired'].includes(status) ||\n (status !== 'latent' && !hasIntroducer)\n );\n return false;\n });\n}\n\n/**\n * Whether an opportunity should appear on the viewer's home feed (actionable =\n * has a pending action for this user).\n *\n * Rules (see `docs/Latent Opportunity Lifecycle.md` — Role-Visibility Matrix):\n *\n * (1) `latent`, no introducer → all actors actionable\n * (2) `latent`, introducer `approved !== true` → introducer only\n * (3) `latent`, introducer `approved === true` → all non-introducer actors\n * (4) `pending` (any introducer config) → all non-introducer actors\n * (5) `accepted`/`rejected`/`expired`/`stalled`/`draft`/`negotiating`\n * → never actionable\n *\n * The introducer approval signal is stored on the `introducer`-roled actor's\n * `approved: boolean` field within the opportunity's `actors` JSONB. It flips\n * from `false` to `true` when the introducer approves; status stays `latent`\n * across the flip while a background negotiation runs.\n */\nexport function isActionableForViewer(\n actors: Array<{ userId: string; role: string; approved?: boolean }>,\n status: string,\n viewerId: string\n): boolean {\n const viewerActors = actors.filter((a) => a.userId === viewerId);\n if (viewerActors.length === 0) return false;\n\n const introducer = actors.find((a) => a.role === 'introducer');\n const hasIntroducer = !!introducer;\n const introducerApproved = introducer?.approved === true;\n\n return viewerActors.some(({ role }) => {\n if (role === 'introducer') {\n // Rule 2: introducer sees own latent opp only while not yet approved.\n return status === 'latent' && !introducerApproved;\n }\n\n // Non-introducer actors: patient / party / agent / peer.\n if (status === 'latent') {\n // Rule 1: no introducer → visible.\n // Rule 3: introducer approved → visible.\n return !hasIntroducer || introducerApproved;\n }\n if (status === 'pending') {\n // Rule 4: visible to all non-introducer actors.\n return true;\n }\n // Rule 5: never actionable at terminal or internal statuses.\n return false;\n });\n}\n\n/** Feed category for home composition. */\nexport type FeedCategory = 'connection' | 'connector-flow' | 'expired';\n\n/** Soft targets for home feed composition. */\nexport const FEED_SOFT_TARGETS = {\n connection: 3,\n connectorFlow: 2,\n expired: 2,\n} as const;\n\n/**\n * Classify an actionable opportunity into a feed category.\n * Assumes the opportunity already passed isActionableForViewer or is expired.\n *\n * @param opp - Opportunity with actors and status\n * @param viewerId - The viewing user's ID\n * @returns Feed category\n */\nexport function classifyOpportunity(\n opp: { actors: Array<{ userId: string; role: string }>; status: string },\n viewerId: string\n): FeedCategory {\n if (opp.status === 'expired') return 'expired';\n const viewerIsIntroducer = opp.actors.some((a) => a.userId === viewerId && a.role === 'introducer');\n if (viewerIsIntroducer) return 'connector-flow';\n return 'connection';\n}\n\n/**\n * Select opportunities for the home feed using soft composition targets.\n * Fills each category up to its target, then redistributes unused slots\n * to categories that have more items available. Preserves input order.\n *\n * @param opportunities - Pre-sorted opportunities (by confidence/recency)\n * @param viewerId - The viewing user's ID\n * @returns Composition-balanced subset\n */\nexport function selectByComposition<T extends { actors: Array<{ userId: string; role: string }>; status: string }>(\n opportunities: T[],\n viewerId: string\n): T[] {\n const buckets: Record<FeedCategory, T[]> = {\n connection: [],\n 'connector-flow': [],\n expired: [],\n };\n\n for (const opp of opportunities) {\n const category = classifyOpportunity(opp, viewerId);\n buckets[category].push(opp);\n }\n\n const targets: Record<FeedCategory, number> = {\n connection: FEED_SOFT_TARGETS.connection,\n 'connector-flow': FEED_SOFT_TARGETS.connectorFlow,\n expired: FEED_SOFT_TARGETS.expired,\n };\n\n // First pass: fill each category up to its target\n const selected: Record<FeedCategory, T[]> = {\n connection: buckets.connection.slice(0, targets.connection),\n 'connector-flow': buckets['connector-flow'].slice(0, targets['connector-flow']),\n expired: buckets.expired.slice(0, targets.expired),\n };\n\n // Calculate unused slots and remaining items\n const totalTarget = targets.connection + targets['connector-flow'] + targets.expired;\n const usedSlots = selected.connection.length + selected['connector-flow'].length + selected.expired.length;\n let unusedSlots = totalTarget - usedSlots;\n\n // Second pass: redistribute unused slots to categories with remaining items\n // Priority: connection > connector-flow > expired\n const redistOrder: FeedCategory[] = ['connection', 'connector-flow', 'expired'];\n for (const category of redistOrder) {\n if (unusedSlots <= 0) break;\n const remaining = buckets[category].slice(selected[category].length);\n const take = Math.min(remaining.length, unusedSlots);\n selected[category].push(...remaining.slice(0, take));\n unusedSlots -= take;\n }\n\n // Merge in category priority order: connection > connector-flow > expired\n // Within each category, preserve original input order\n const indexMap = new Map(opportunities.map((opp, i) => [opp, i]));\n const sortByOriginal = (a: T, b: T) => (indexMap.get(a) ?? 0) - (indexMap.get(b) ?? 0);\n selected.connection.sort(sortByOriginal);\n selected['connector-flow'].sort(sortByOriginal);\n selected.expired.sort(sortByOriginal);\n\n logger.info(`[selectByComposition] input=${opportunities.length} buckets: connection=${buckets.connection.length} connector-flow=${buckets['connector-flow'].length} expired=${buckets.expired.length} → selected: connection=${selected.connection.length} connector-flow=${selected['connector-flow'].length} expired=${selected.expired.length}`);\n\n return [\n ...selected.connection,\n ...selected['connector-flow'],\n ...selected.expired,\n ];\n}\n\n/**\n * Deduplicate opportunities so each counterpart appears at most once.\n * Keeps the opportunity with the highest interpretation.confidence per\n * counterpart userId. On ties, the first encountered wins (stable).\n *\n * Counterpart = first actor whose userId !== viewerId and role !== 'introducer'.\n * Opportunities without a derivable counterpart pass through undeduped.\n *\n * @param opportunities - Pre-sorted opportunities (e.g. by confidence/recency)\n * @param viewerId - The viewing user's ID\n * @returns Deduped subset preserving original input order among winners\n */\nexport function deduplicateByPerson<T extends {\n actors: Array<{ userId: string; role: string }>;\n interpretation?: { confidence?: number } | null;\n}>(opportunities: T[], viewerId: string): T[] {\n const bestByCounterpart = new Map<string, { opp: T; index: number }>();\n const noCounterpart: Array<{ opp: T; index: number }> = [];\n\n for (let i = 0; i < opportunities.length; i++) {\n const opp = opportunities[i];\n const counterpart = opp.actors.find(\n (a) => a.userId !== viewerId && a.role !== 'introducer',\n );\n\n if (!counterpart) {\n noCounterpart.push({ opp, index: i });\n continue;\n }\n\n const key = counterpart.userId;\n const existing = bestByCounterpart.get(key);\n\n if (!existing) {\n bestByCounterpart.set(key, { opp, index: i });\n continue;\n }\n\n const newConf = opp.interpretation?.confidence ?? -1;\n const oldConf = existing.opp.interpretation?.confidence ?? -1;\n if (newConf > oldConf) {\n bestByCounterpart.set(key, { opp, index: i });\n }\n }\n\n const all = [...bestByCounterpart.values(), ...noCounterpart];\n all.sort((a, b) => a.index - b.index);\n\n const result = all.map((entry) => entry.opp);\n if (result.length < opportunities.length) {\n logger.info(\n `[deduplicateByPerson] deduped ${opportunities.length} → ${result.length} opportunities`,\n );\n }\n return result;\n}\n\n/**\n * Days a digest-delivered opportunity stays suppressed before it becomes\n * eligible for a \"still open\" reminder re-show (when nothing fresh exists).\n */\nexport const DIGEST_REDELIVERY_COOLDOWN_DAYS = 5;\n\n/** Committed delivery row shape consumed by {@link selectDigestCandidates}. */\nexport interface DigestDeliveredRow {\n opportunityId: string;\n deliveredAtStatus: string;\n deliveredAt: Date;\n}\n\n/**\n * Cross-day digest suppression for scheduled-brief candidates.\n *\n * Three rules, applied in order:\n * 1. **Accepted-counterpart suppression** — a direct-connection candidate whose\n * counterpart the viewer has already connected with (an `accepted`\n * opportunity exists with that person) is dropped permanently. A new\n * discovery run re-minting the same person must not resurface them.\n * Connector-flow candidates (viewer is the introducer) are exempt: being\n * connected with someone doesn't make an intro ask on their behalf stale.\n * 2. **Delivery-ledger dedup** — candidates with a committed delivery row at\n * the same `(opportunityId, status)` key have already been shown. While any\n * fresh (never-shown) candidate exists, shown ones are dropped entirely.\n * 3. **Cooldown re-show** — when *no* fresh candidate survives, already-shown\n * candidates whose latest delivery is at least `cooldownDays` old are\n * returned instead, least-recently-shown first, flagged via\n * `redeliveryIds` so the digest can frame them as reminders.\n *\n * Pure function — callers fetch accepted counterparts and ledger rows.\n *\n * @param candidates - Deduped, confidence-ordered digest candidates.\n * @param opts.viewerId - The digest recipient.\n * @param opts.acceptedCounterpartIds - userIds the viewer already connected with.\n * @param opts.deliveredRows - Committed ledger rows for the candidate ids.\n * @param opts.now - Clock override for tests.\n * @param opts.cooldownDays - Cooldown override (default {@link DIGEST_REDELIVERY_COOLDOWN_DAYS}).\n * @returns Surviving pool plus the set of candidate ids that are cooldown re-shows.\n */\nexport function selectDigestCandidates<T extends {\n id: string;\n status: string;\n actors: Array<{ userId: string; role: string }>;\n}>(\n candidates: T[],\n opts: {\n viewerId: string;\n acceptedCounterpartIds: ReadonlySet<string>;\n deliveredRows: DigestDeliveredRow[];\n now?: Date;\n cooldownDays?: number;\n },\n): { pool: T[]; redeliveryIds: Set<string> } {\n const { viewerId, acceptedCounterpartIds } = opts;\n\n // Rule 1: accepted-counterpart suppression (direct connections only).\n const afterAccepted = candidates.filter((opp) => {\n const viewerIsIntroducer = opp.actors.some(\n (a) => a.role === 'introducer' && a.userId === viewerId,\n );\n if (viewerIsIntroducer) return true;\n const counterpart = opp.actors.find(\n (a) => a.userId !== viewerId && a.role !== 'introducer',\n );\n return !counterpart || !acceptedCounterpartIds.has(counterpart.userId);\n });\n if (afterAccepted.length < candidates.length) {\n logger.info(\n `[selectDigestCandidates] accepted-counterpart suppression dropped ${candidates.length - afterAccepted.length} of ${candidates.length} candidates`,\n );\n }\n\n // Rule 2: delivery-ledger dedup keyed (opportunityId, deliveredAtStatus).\n // Keep the LATEST committed delivery per key — cooldown measures time since\n // the user last saw the card, not since they first saw it.\n const lastDeliveredByKey = new Map<string, Date>();\n for (const row of opts.deliveredRows) {\n if (!(row.deliveredAt instanceof Date) || Number.isNaN(row.deliveredAt.getTime())) continue;\n const key = `${row.opportunityId}:${row.deliveredAtStatus}`;\n const existing = lastDeliveredByKey.get(key);\n if (!existing || row.deliveredAt > existing) lastDeliveredByKey.set(key, row.deliveredAt);\n }\n\n const fresh = afterAccepted.filter((opp) => !lastDeliveredByKey.has(`${opp.id}:${opp.status}`));\n if (fresh.length > 0) {\n if (fresh.length < afterAccepted.length) {\n logger.info(\n `[selectDigestCandidates] ledger dedup dropped ${afterAccepted.length - fresh.length} already-shown candidates`,\n );\n }\n return { pool: fresh, redeliveryIds: new Set<string>() };\n }\n\n // Rule 3: nothing fresh — re-show the least-recently-shown candidates past cooldown.\n const cooldownMs = (opts.cooldownDays ?? DIGEST_REDELIVERY_COOLDOWN_DAYS) * 86_400_000;\n const now = opts.now ?? new Date();\n const cooled = afterAccepted\n .map((opp) => ({ opp, at: lastDeliveredByKey.get(`${opp.id}:${opp.status}`) }))\n .filter((entry): entry is { opp: T; at: Date } =>\n entry.at instanceof Date && now.getTime() - entry.at.getTime() >= cooldownMs,\n )\n .sort((a, b) => a.at.getTime() - b.at.getTime());\n\n if (cooled.length > 0) {\n logger.info(\n `[selectDigestCandidates] no fresh candidates — re-showing ${cooled.length} past-cooldown candidate(s)`,\n );\n }\n return {\n pool: cooled.map((entry) => entry.opp),\n redeliveryIds: new Set(cooled.map((entry) => entry.opp.id)),\n };\n}\n"]}
1
+ {"version":3,"file":"opportunity.utils.js","sourceRoot":"/","sources":["opportunity/opportunity.utils.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Opportunity graph utilities: role derivation from corpus type.\n * Used by the opportunity graph to map lens corpus to opportunity actor roles.\n *\n * With lens-based HyDE, strategy selection is handled automatically by the\n * LensInferrer agent. This file provides corpus-to-role mapping for opportunity actors.\n */\n\nimport type { HydeTargetCorpus } from '../shared/hyde/lens.inferrer.js';\nimport { log } from '../shared/observability/log.js';\n\nconst logger = log.graph.from('SelectByComposition');\n\n/** Actor roles in the opportunity model (agent / patient / peer). */\nexport type OpportunityActorRole = 'agent' | 'patient' | 'peer';\n\n/** Result of mapping a corpus to source and candidate roles. */\nexport interface DerivedRoles {\n sourceRole: OpportunityActorRole;\n candidateRole: OpportunityActorRole;\n}\n\n/**\n * Derive actor roles from the corpus type of a lens match.\n *\n * When a candidate is found via:\n * - \"profiles\" corpus → found by who they are → candidate can help → agent\n * - \"intents\" corpus → found by what they need → candidate needs something → patient\n *\n * @param corpus - The target corpus that produced the match ('profiles' | 'intents')\n * @returns Roles for the source (intent owner) and the candidate (matched user/intent)\n */\nexport function deriveRolesFromCorpus(corpus: HydeTargetCorpus): DerivedRoles {\n switch (corpus) {\n case 'profiles':\n // Source seeks someone who can help → source is patient, candidate can help → agent\n return { sourceRole: 'patient', candidateRole: 'agent' };\n case 'intents':\n // Source offers or needs; candidate has complementary goal → source is agent, candidate is patient\n return { sourceRole: 'agent', candidateRole: 'patient' };\n case 'premises':\n // Premise matches are symmetric: two people whose self-descriptions align.\n // Unlike intents (directional roles), premises express stable identity truths.\n return { sourceRole: 'peer', candidateRole: 'peer' };\n default:\n return { sourceRole: 'peer', candidateRole: 'peer' };\n }\n}\n\n/**\n * Validates opportunity actors: if an opportunity has an introducer, it must have\n * one or two non-introducer actors (1 = 1:1 intro e.g. \"I want to connect with X\";\n * 2 = introducer connecting two others).\n *\n * Also rejects self-matches — the same person occupying both sides of a\n * connection. The discovery/persist pipeline trusts the LLM evaluator's actor\n * list, which can collapse onto a single user; downstream readers then garble\n * identity (e.g. a connect link/greeting rendered in one party's voice while the\n * card shows the viewer \"matched with themselves\"). Two degenerate shapes are\n * blocked here, at the single persist chokepoint:\n * - every userId-bearing non-introducer actor collapses to the same user,\n * e.g. `[X(agent), X(patient)]`\n * - an introducer who is also a participant (\"Amina introduced you to Amina\")\n * Only `userId`-bearing actors are checked; role-only actors (legacy/tests) pass.\n * Duplicate rows for one participant are allowed when at least one other distinct\n * participant is present (some callers model multiple intents as multiple actor rows).\n *\n * @param actors - Array of actors with at least a role and optional userId\n * @throws Error when the actor set is invalid\n */\nexport function validateOpportunityActors(actors: Array<{ userId?: string; role: string }>): void {\n const introducerCount = actors.filter((a) => a.role === 'introducer').length;\n const nonIntroducerCount = actors.filter((a) => a.role !== 'introducer').length;\n\n if (introducerCount > 0 && (nonIntroducerCount < 1 || nonIntroducerCount > 2)) {\n throw new Error(\n 'An opportunity with an introducer must have one or two other actors.'\n );\n }\n\n // Self-match guard. Compare only actors that carry a userId so role-only\n // shapes (used by some callers/tests) are unaffected.\n const introducerUserIds = new Set(\n actors.filter((a) => a.role === 'introducer' && a.userId).map((a) => a.userId as string),\n );\n const nonIntroducerUserIds = actors\n .filter((a) => a.role !== 'introducer' && a.userId)\n .map((a) => a.userId as string);\n\n for (const userId of nonIntroducerUserIds) {\n if (introducerUserIds.has(userId)) {\n throw new Error(\n 'An opportunity actor cannot be both the introducer and a participant (self-match).'\n );\n }\n }\n\n const uniqueNonIntroducerUserIds = new Set(nonIntroducerUserIds);\n if (nonIntroducerUserIds.length > 1 && uniqueNonIntroducerUserIds.size === 1) {\n throw new Error('An opportunity cannot match a user with themselves (duplicate participant).');\n }\n}\n\n/**\n * Read-level ACL: whether a user is an actor on the opportunity and may fetch\n * its details. Intentionally broader than `isActionableForViewer` — a user can\n * read an opportunity they are not currently expected to act on (e.g. an agent\n * viewing an accepted opportunity).\n *\n * The feed graph and debug controller chain both predicates: an opportunity only\n * reaches the home feed if it passes `canUserSeeOpportunity` first, then\n * `isActionableForViewer`. For `agent with introducer at pending`,\n * `canUserSeeOpportunity` returns false (read gate blocks it), so the opportunity\n * never surfaces even though `isActionableForViewer` Rule 4 would return true in\n * isolation. This is by design — the agent is not granted read access through the\n * home path until the introducer path completes (negotiation → accepted).\n *\n * Compact Visibility Rule (from lifecycle doc):\n * - Introducer or peer: always see.\n * - Patient or party: see if (status is not latent, or there is no introducer).\n * - Agent: see if (status is accepted/rejected/expired, or (status is not latent and there is no introducer)).\n */\nexport function canUserSeeOpportunity(\n actors: Array<{ userId: string; role: string }>,\n status: string,\n userId: string\n): boolean {\n const hasIntroducer = actors.some((a) => a.role === 'introducer');\n const userRoles = actors.filter((a) => a.userId === userId).map((a) => a.role);\n if (userRoles.length === 0) return false;\n\n return userRoles.some((role) => {\n if (role === 'introducer') return true;\n if (role === 'peer') return true;\n if (role === 'patient' || role === 'party')\n return status !== 'latent' || !hasIntroducer;\n if (role === 'agent')\n return (\n ['accepted', 'rejected', 'expired'].includes(status) ||\n (status !== 'latent' && !hasIntroducer)\n );\n return false;\n });\n}\n\n/**\n * Whether an opportunity should appear on the viewer's home feed (actionable =\n * has a pending action for this user).\n *\n * Rules (see `docs/Latent Opportunity Lifecycle.md` — Role-Visibility Matrix):\n *\n * (1) `latent`, no introducer → all actors actionable\n * (2) `latent`, introducer `approved !== true` → introducer only\n * (3) `latent`, introducer `approved === true` → all non-introducer actors\n * (4) `pending` (any introducer config) → non-introducer actors who have not acted\n * (5) `accepted`/`rejected`/`expired`/`stalled`/`draft`/`negotiating`\n * → never actionable\n *\n * The introducer approval signal is stored on the `introducer`-roled actor's\n * `approved: boolean` field within the opportunity's `actors` JSONB. It flips\n * from `false` to `true` when the introducer approves; status stays `latent`\n * across the flip while a background negotiation runs.\n */\nexport function isActionableForViewer(\n actors: Array<{ userId: string; role: string; approved?: boolean; actedAt?: string | null }>,\n status: string,\n viewerId: string\n): boolean {\n const viewerActors = actors.filter((a) => a.userId === viewerId);\n if (viewerActors.length === 0) return false;\n\n const introducer = actors.find((a) => a.role === 'introducer');\n const hasIntroducer = !!introducer;\n const introducerApproved = introducer?.approved === true;\n\n return viewerActors.some(({ role, actedAt }) => {\n if (role === 'introducer') {\n // Rule 2: introducer sees own latent opp only while not yet approved.\n return status === 'latent' && !introducerApproved;\n }\n\n // Non-introducer actors: patient / party / agent / peer.\n if (status === 'latent') {\n // Rule 1: no introducer → visible.\n // Rule 3: introducer approved → visible.\n return !hasIntroducer || introducerApproved;\n }\n if (status === 'pending') {\n // Rule 4: pending is actionable only for actors who have not already\n // acted. Once an actor has `actedAt`, the opportunity is waiting on the\n // counterparty and should not appear in that actor's home feed.\n return !actedAt;\n }\n // Rule 5: never actionable at terminal or internal statuses.\n return false;\n });\n}\n\n/** Feed category for home composition. */\nexport type FeedCategory = 'connection' | 'connector-flow' | 'expired';\n\n/** Soft targets for home feed composition. */\nexport const FEED_SOFT_TARGETS = {\n connection: 3,\n connectorFlow: 2,\n expired: 2,\n} as const;\n\n/**\n * Classify an actionable opportunity into a feed category.\n * Assumes the opportunity already passed isActionableForViewer or is expired.\n *\n * @param opp - Opportunity with actors and status\n * @param viewerId - The viewing user's ID\n * @returns Feed category\n */\nexport function classifyOpportunity(\n opp: { actors: Array<{ userId: string; role: string }>; status: string },\n viewerId: string\n): FeedCategory {\n if (opp.status === 'expired') return 'expired';\n const viewerIsIntroducer = opp.actors.some((a) => a.userId === viewerId && a.role === 'introducer');\n if (viewerIsIntroducer) return 'connector-flow';\n return 'connection';\n}\n\n/**\n * Select opportunities for the home feed using soft composition targets.\n * Fills each category up to its target, then redistributes unused slots\n * to categories that have more items available. Preserves input order.\n *\n * @param opportunities - Pre-sorted opportunities (by confidence/recency)\n * @param viewerId - The viewing user's ID\n * @returns Composition-balanced subset\n */\nexport function selectByComposition<T extends { actors: Array<{ userId: string; role: string }>; status: string }>(\n opportunities: T[],\n viewerId: string\n): T[] {\n const buckets: Record<FeedCategory, T[]> = {\n connection: [],\n 'connector-flow': [],\n expired: [],\n };\n\n for (const opp of opportunities) {\n const category = classifyOpportunity(opp, viewerId);\n buckets[category].push(opp);\n }\n\n const targets: Record<FeedCategory, number> = {\n connection: FEED_SOFT_TARGETS.connection,\n 'connector-flow': FEED_SOFT_TARGETS.connectorFlow,\n expired: FEED_SOFT_TARGETS.expired,\n };\n\n // First pass: fill each category up to its target\n const selected: Record<FeedCategory, T[]> = {\n connection: buckets.connection.slice(0, targets.connection),\n 'connector-flow': buckets['connector-flow'].slice(0, targets['connector-flow']),\n expired: buckets.expired.slice(0, targets.expired),\n };\n\n // Calculate unused slots and remaining items\n const totalTarget = targets.connection + targets['connector-flow'] + targets.expired;\n const usedSlots = selected.connection.length + selected['connector-flow'].length + selected.expired.length;\n let unusedSlots = totalTarget - usedSlots;\n\n // Second pass: redistribute unused slots to categories with remaining items\n // Priority: connection > connector-flow > expired\n const redistOrder: FeedCategory[] = ['connection', 'connector-flow', 'expired'];\n for (const category of redistOrder) {\n if (unusedSlots <= 0) break;\n const remaining = buckets[category].slice(selected[category].length);\n const take = Math.min(remaining.length, unusedSlots);\n selected[category].push(...remaining.slice(0, take));\n unusedSlots -= take;\n }\n\n // Merge in category priority order: connection > connector-flow > expired\n // Within each category, preserve original input order\n const indexMap = new Map(opportunities.map((opp, i) => [opp, i]));\n const sortByOriginal = (a: T, b: T) => (indexMap.get(a) ?? 0) - (indexMap.get(b) ?? 0);\n selected.connection.sort(sortByOriginal);\n selected['connector-flow'].sort(sortByOriginal);\n selected.expired.sort(sortByOriginal);\n\n logger.info(`[selectByComposition] input=${opportunities.length} buckets: connection=${buckets.connection.length} connector-flow=${buckets['connector-flow'].length} expired=${buckets.expired.length} → selected: connection=${selected.connection.length} connector-flow=${selected['connector-flow'].length} expired=${selected.expired.length}`);\n\n return [\n ...selected.connection,\n ...selected['connector-flow'],\n ...selected.expired,\n ];\n}\n\n/**\n * Deduplicate opportunities so each counterpart appears at most once.\n * Keeps the opportunity with the highest interpretation.confidence per\n * counterpart userId. On ties, the first encountered wins (stable).\n *\n * Counterpart = first actor whose userId !== viewerId and role !== 'introducer'.\n * Opportunities without a derivable counterpart pass through undeduped.\n *\n * @param opportunities - Pre-sorted opportunities (e.g. by confidence/recency)\n * @param viewerId - The viewing user's ID\n * @returns Deduped subset preserving original input order among winners\n */\nexport function deduplicateByPerson<T extends {\n actors: Array<{ userId: string; role: string }>;\n interpretation?: { confidence?: number } | null;\n}>(opportunities: T[], viewerId: string): T[] {\n const bestByCounterpart = new Map<string, { opp: T; index: number }>();\n const noCounterpart: Array<{ opp: T; index: number }> = [];\n\n for (let i = 0; i < opportunities.length; i++) {\n const opp = opportunities[i];\n const counterpart = opp.actors.find(\n (a) => a.userId !== viewerId && a.role !== 'introducer',\n );\n\n if (!counterpart) {\n noCounterpart.push({ opp, index: i });\n continue;\n }\n\n const key = counterpart.userId;\n const existing = bestByCounterpart.get(key);\n\n if (!existing) {\n bestByCounterpart.set(key, { opp, index: i });\n continue;\n }\n\n const newConf = opp.interpretation?.confidence ?? -1;\n const oldConf = existing.opp.interpretation?.confidence ?? -1;\n if (newConf > oldConf) {\n bestByCounterpart.set(key, { opp, index: i });\n }\n }\n\n const all = [...bestByCounterpart.values(), ...noCounterpart];\n all.sort((a, b) => a.index - b.index);\n\n const result = all.map((entry) => entry.opp);\n if (result.length < opportunities.length) {\n logger.info(\n `[deduplicateByPerson] deduped ${opportunities.length} → ${result.length} opportunities`,\n );\n }\n return result;\n}\n\n/**\n * Days a digest-delivered opportunity stays suppressed before it becomes\n * eligible for a \"still open\" reminder re-show (when nothing fresh exists).\n */\nexport const DIGEST_REDELIVERY_COOLDOWN_DAYS = 5;\n\n/** Committed delivery row shape consumed by {@link selectDigestCandidates}. */\nexport interface DigestDeliveredRow {\n opportunityId: string;\n deliveredAtStatus: string;\n deliveredAt: Date;\n}\n\n/**\n * Cross-day digest suppression for scheduled-brief candidates.\n *\n * Three rules, applied in order:\n * 1. **Accepted-counterpart suppression** — a direct-connection candidate whose\n * counterpart the viewer has already connected with (an `accepted`\n * opportunity exists with that person) is dropped permanently. A new\n * discovery run re-minting the same person must not resurface them.\n * Connector-flow candidates (viewer is the introducer) are exempt: being\n * connected with someone doesn't make an intro ask on their behalf stale.\n * 2. **Delivery-ledger dedup** — candidates with a committed delivery row at\n * the same `(opportunityId, status)` key have already been shown. While any\n * fresh (never-shown) candidate exists, shown ones are dropped entirely.\n * 3. **Cooldown re-show** — when *no* fresh candidate survives, already-shown\n * candidates whose latest delivery is at least `cooldownDays` old are\n * returned instead, least-recently-shown first, flagged via\n * `redeliveryIds` so the digest can frame them as reminders.\n *\n * Pure function — callers fetch accepted counterparts and ledger rows.\n *\n * @param candidates - Deduped, confidence-ordered digest candidates.\n * @param opts.viewerId - The digest recipient.\n * @param opts.acceptedCounterpartIds - userIds the viewer already connected with.\n * @param opts.deliveredRows - Committed ledger rows for the candidate ids.\n * @param opts.now - Clock override for tests.\n * @param opts.cooldownDays - Cooldown override (default {@link DIGEST_REDELIVERY_COOLDOWN_DAYS}).\n * @returns Surviving pool plus the set of candidate ids that are cooldown re-shows.\n */\nexport function selectDigestCandidates<T extends {\n id: string;\n status: string;\n actors: Array<{ userId: string; role: string }>;\n}>(\n candidates: T[],\n opts: {\n viewerId: string;\n acceptedCounterpartIds: ReadonlySet<string>;\n deliveredRows: DigestDeliveredRow[];\n now?: Date;\n cooldownDays?: number;\n },\n): { pool: T[]; redeliveryIds: Set<string> } {\n const { viewerId, acceptedCounterpartIds } = opts;\n\n // Rule 1: accepted-counterpart suppression (direct connections only).\n const afterAccepted = candidates.filter((opp) => {\n const viewerIsIntroducer = opp.actors.some(\n (a) => a.role === 'introducer' && a.userId === viewerId,\n );\n if (viewerIsIntroducer) return true;\n const counterpart = opp.actors.find(\n (a) => a.userId !== viewerId && a.role !== 'introducer',\n );\n return !counterpart || !acceptedCounterpartIds.has(counterpart.userId);\n });\n if (afterAccepted.length < candidates.length) {\n logger.info(\n `[selectDigestCandidates] accepted-counterpart suppression dropped ${candidates.length - afterAccepted.length} of ${candidates.length} candidates`,\n );\n }\n\n // Rule 2: delivery-ledger dedup keyed (opportunityId, deliveredAtStatus).\n // Keep the LATEST committed delivery per key — cooldown measures time since\n // the user last saw the card, not since they first saw it.\n const lastDeliveredByKey = new Map<string, Date>();\n for (const row of opts.deliveredRows) {\n if (!(row.deliveredAt instanceof Date) || Number.isNaN(row.deliveredAt.getTime())) continue;\n const key = `${row.opportunityId}:${row.deliveredAtStatus}`;\n const existing = lastDeliveredByKey.get(key);\n if (!existing || row.deliveredAt > existing) lastDeliveredByKey.set(key, row.deliveredAt);\n }\n\n const fresh = afterAccepted.filter((opp) => !lastDeliveredByKey.has(`${opp.id}:${opp.status}`));\n if (fresh.length > 0) {\n if (fresh.length < afterAccepted.length) {\n logger.info(\n `[selectDigestCandidates] ledger dedup dropped ${afterAccepted.length - fresh.length} already-shown candidates`,\n );\n }\n return { pool: fresh, redeliveryIds: new Set<string>() };\n }\n\n // Rule 3: nothing fresh — re-show the least-recently-shown candidates past cooldown.\n const cooldownMs = (opts.cooldownDays ?? DIGEST_REDELIVERY_COOLDOWN_DAYS) * 86_400_000;\n const now = opts.now ?? new Date();\n const cooled = afterAccepted\n .map((opp) => ({ opp, at: lastDeliveredByKey.get(`${opp.id}:${opp.status}`) }))\n .filter((entry): entry is { opp: T; at: Date } =>\n entry.at instanceof Date && now.getTime() - entry.at.getTime() >= cooldownMs,\n )\n .sort((a, b) => a.at.getTime() - b.at.getTime());\n\n if (cooled.length > 0) {\n logger.info(\n `[selectDigestCandidates] no fresh candidates — re-showing ${cooled.length} past-cooldown candidate(s)`,\n );\n }\n return {\n pool: cooled.map((entry) => entry.opp),\n redeliveryIds: new Set(cooled.map((entry) => entry.opp.id)),\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indexnetwork/protocol",
3
- "version": "4.3.4-rc.312.1",
3
+ "version": "4.3.5-rc.313.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",