/** * Opportunity graph utilities: role derivation from corpus type. * Used by the opportunity graph to map lens corpus to opportunity actor roles. * * With lens-based HyDE, strategy selection is handled automatically by the * LensInferrer agent. This file provides corpus-to-role mapping for opportunity actors. */ import type { HydeTargetCorpus } from '../../shared/hyde/lens.inferrer.js'; /** Actor roles in the opportunity model (agent / patient / peer). */ export type OpportunityActorRole = 'agent' | 'patient' | 'peer'; /** Result of mapping a corpus to source and candidate roles. */ export interface DerivedRoles { sourceRole: OpportunityActorRole; candidateRole: OpportunityActorRole; } /** * Derive actor roles from the corpus type of a lens match. * * When a candidate is found via: * - "profiles" corpus → found by who they are → candidate can help → agent * - "intents" corpus → found by what they need → candidate needs something → patient * * @param corpus - The target corpus that produced the match ('profiles' | 'intents') * @returns Roles for the source (intent owner) and the candidate (matched user/intent) */ export declare function deriveRolesFromCorpus(corpus: HydeTargetCorpus): DerivedRoles; /** * Validates opportunity actors: if an opportunity has an introducer, it must have * one or two non-introducer actors (1 = 1:1 intro e.g. "I want to connect with X"; * 2 = introducer connecting two others). * * Also rejects self-matches — the same person occupying both sides of a * connection. The discovery/persist pipeline trusts the LLM evaluator's actor * list, which can collapse onto a single user; downstream readers then garble * identity (e.g. a connect link/greeting rendered in one party's voice while the * card shows the viewer "matched with themselves"). Two degenerate shapes are * blocked here, at the single persist chokepoint: * - every userId-bearing non-introducer actor collapses to the same user, * e.g. `[X(agent), X(patient)]` * - an introducer who is also a participant ("Amina introduced you to Amina") * Only `userId`-bearing actors are checked; role-only actors (legacy/tests) pass. * Duplicate rows for one participant are allowed when at least one other distinct * participant is present (some callers model multiple intents as multiple actor rows). * * @param actors - Array of actors with at least a role and optional userId * @throws Error when the actor set is invalid */ export declare function validateOpportunityActors(actors: Array<{ userId?: string; role: string; }>): void; /** * Read-level ACL: whether a user is an actor on the opportunity and may fetch * its details. Intentionally broader than `isActionableForViewer` — a user can * read an opportunity they are not currently expected to act on (e.g. an agent * viewing an accepted opportunity). * * The feed graph and debug controller chain both predicates: an opportunity only * reaches the radar view if it passes `canUserSeeOpportunity` first, then * `isActionableForViewer`. For `agent with introducer at pending`, * `canUserSeeOpportunity` returns false (read gate blocks it), so the opportunity * never surfaces even though `isActionableForViewer` Rule 4 would return true in * isolation. This is by design — the agent is not granted read access through the * home path until the introducer path completes (negotiation → accepted). * * Compact Visibility Rule (see `docs/design/opportunity-status-lifecycle.md`, §3.E): * - Introducer or peer: always see. * - Patient or party: see if (status is not latent, or there is no introducer). * - Agent: see if (status is accepted/rejected/expired, or (status is not latent and there is no introducer)). */ export declare function canUserSeeOpportunity(actors: Array<{ userId: string; role: string; }>, status: string, userId: string): boolean; /** * Whether an opportunity should appear on the viewer's radar (actionable = * has a pending action for this user). * * Rules (see `docs/design/opportunity-status-lifecycle.md`, §3.E): * * (1) `latent`, no introducer → all actors actionable * (2) `latent`, introducer `approved !== true` → introducer only * (3) `latent`, introducer `approved === true` → all non-introducer actors * (4) `pending` (any introducer config) → non-introducer actors who have not acted. * Acting is per-user, not per-actor-row: re-detection can append duplicate * actor rows for the same user without `actedAt`, so any viewer row with * `actedAt` means the viewer has already acted. * (5) `accepted`/`rejected`/`expired`/`stalled`/`draft`/`negotiating` * → never actionable * * The introducer approval signal is stored on the `introducer`-roled actor's * `approved: boolean` field within the opportunity's `actors` JSONB. It flips * from `false` to `true` when the introducer approves; status stays `latent` * across the flip while a background negotiation runs. */ export declare function isActionableForViewer(actors: Array<{ userId: string; role: string; approved?: boolean; actedAt?: string | null; }>, status: string, viewerId: string): boolean; /** Feed category for home composition. */ export type FeedCategory = 'connection' | 'connector-flow' | 'expired'; /** Soft targets for radar composition. */ export declare const RADAR_SOFT_TARGETS: { readonly connection: 3; readonly connectorFlow: 2; readonly expired: 2; }; /** * Classify an actionable opportunity into a feed category. * Assumes the opportunity already passed isActionableForViewer or is expired. * * @param opp - Opportunity with actors and status * @param viewerId - The viewing user's ID * @returns Feed category */ export declare function classifyOpportunity(opp: { actors: Array<{ userId: string; role: string; }>; status: string; }, viewerId: string): FeedCategory; /** * Select opportunities for the radar using soft composition targets. * Fills each category up to its target, then redistributes unused slots * to categories that have more items available. Preserves input order. * * @param opportunities - Pre-sorted opportunities (by confidence/recency) * @param viewerId - The viewing user's ID * @returns Composition-balanced subset */ export declare function selectByComposition; status: string; }>(opportunities: T[], viewerId: string): T[]; /** * Deduplicate opportunities so each counterpart appears at most once. * Keeps the opportunity with the highest interpretation.confidence per * counterpart userId. On ties, the first encountered wins (stable). * * Counterpart = first actor whose userId !== viewerId and role !== 'introducer'. * Opportunities without a derivable counterpart pass through undeduped. * * @param opportunities - Pre-sorted opportunities (e.g. by confidence/recency) * @param viewerId - The viewing user's ID * @returns Deduped subset preserving original input order among winners */ export declare function deduplicateByPerson; interpretation?: { confidence?: number; } | null; }>(opportunities: T[], viewerId: string): T[]; /** * Days a digest-delivered opportunity stays suppressed before it becomes * eligible for a "still open" reminder re-show (when nothing fresh exists). */ export declare const DIGEST_REDELIVERY_COOLDOWN_DAYS = 5; /** Committed delivery row shape consumed by {@link selectDigestCandidates}. */ export interface DigestDeliveredRow { opportunityId: string; deliveredAtStatus: string; deliveredAt: Date; } /** * Cross-day digest suppression for scheduled-brief candidates. * * Three rules, applied in order: * 1. **Accepted-counterpart suppression** — a direct-connection candidate whose * counterpart the viewer has already connected with (an `accepted` * opportunity exists with that person) is dropped permanently. A new * discovery run re-minting the same person must not resurface them. * Connector-flow candidates (viewer is the introducer) are exempt: being * connected with someone doesn't make an intro ask on their behalf stale. * 2. **Delivery-ledger dedup** — candidates with a committed delivery row at * the same `(opportunityId, status)` key have already been shown. While any * fresh (never-shown) candidate exists, shown ones are dropped entirely. * 3. **Cooldown re-show** — when *no* fresh candidate survives, already-shown * candidates whose latest delivery is at least `cooldownDays` old are * returned instead, least-recently-shown first, flagged via * `redeliveryIds` so the digest can frame them as reminders. * * Pure function — callers fetch accepted counterparts and ledger rows. * * @param candidates - Deduped, confidence-ordered digest candidates. * @param opts.viewerId - The digest recipient. * @param opts.acceptedCounterpartIds - userIds the viewer already connected with. * @param opts.deliveredRows - Committed ledger rows for the candidate ids. * @param opts.now - Clock override for tests. * @param opts.cooldownDays - Cooldown override (default {@link DIGEST_REDELIVERY_COOLDOWN_DAYS}). * @returns Surviving pool plus the set of candidate ids that are cooldown re-shows. */ export declare function selectDigestCandidates; }>(candidates: T[], opts: { viewerId: string; acceptedCounterpartIds: ReadonlySet; deliveredRows: DigestDeliveredRow[]; now?: Date; cooldownDays?: number; }): { pool: T[]; redeliveryIds: Set; };