@indexnetwork/protocol 6.14.0-rc.406.1 → 8.0.1-rc.417.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 (114) hide show
  1. package/CHANGELOG.md +307 -0
  2. package/IMPLEMENTATION.md +7 -4
  3. package/dist/capabilities/opportunities.facade.d.ts +4 -0
  4. package/dist/capabilities/opportunities.facade.js +4 -0
  5. package/dist/capabilities/signals.tools.port.d.ts +1 -1
  6. package/dist/chat/chat-streaming.types.d.ts +4 -5
  7. package/dist/chat/chat.agent.d.ts +0 -19
  8. package/dist/chat/chat.agent.js +1 -70
  9. package/dist/chat/chat.persona.d.ts +0 -6
  10. package/dist/chat/chat.persona.js +0 -1
  11. package/dist/chat/chat.prompt.js +10 -10
  12. package/dist/chat/chat.prompt.modules.js +0 -104
  13. package/dist/chat/chat.tools.js +11 -7
  14. package/dist/chat/negotiator.persona.js +0 -1
  15. package/dist/chat/onboarding.persona.js +0 -1
  16. package/dist/chat/reporter.persona.d.ts +1 -1
  17. package/dist/chat/reporter.persona.js +1 -2
  18. package/dist/chat/reporter.prompt.js +5 -5
  19. package/dist/chat/signal.persona.js +0 -1
  20. package/dist/communities/application/network.tools.js +48 -27
  21. package/dist/contacts/application/contact.tools.js +9 -9
  22. package/dist/index.d.ts +9 -2
  23. package/dist/index.js +5 -0
  24. package/dist/maintenance/maintenance.graph.js +7 -0
  25. package/dist/mcp/mcp.authorization-policy.d.ts +250 -0
  26. package/dist/mcp/mcp.authorization-policy.js +522 -0
  27. package/dist/mcp/mcp.server.d.ts +6 -29
  28. package/dist/mcp/mcp.server.js +202 -228
  29. package/dist/negotiation/application/negotiation.agent.js +29 -5
  30. package/dist/negotiation/application/negotiation.graph.d.ts +2 -5
  31. package/dist/negotiation/application/negotiation.graph.js +53 -23
  32. package/dist/negotiation/domain/index.d.ts +3 -0
  33. package/dist/negotiation/domain/index.js +3 -0
  34. package/dist/negotiation/domain/negotiation.deadlock.d.ts +7 -1
  35. package/dist/negotiation/domain/negotiation.deadlock.js +48 -1
  36. package/dist/negotiation/domain/negotiation.stance.contracts.d.ts +85 -0
  37. package/dist/negotiation/domain/negotiation.stance.contracts.js +140 -0
  38. package/dist/negotiation/domain/negotiation.state.d.ts +2 -3
  39. package/dist/negotiation/domain/negotiation.state.js +2 -3
  40. package/dist/negotiation/public/index.d.ts +2 -0
  41. package/dist/negotiation/public/index.js +2 -0
  42. package/dist/opportunity/application/index.d.ts +0 -3
  43. package/dist/opportunity/application/index.js +0 -3
  44. package/dist/opportunity/application/opportunity.graph.d.ts +0 -43
  45. package/dist/opportunity/application/opportunity.graph.js +7 -113
  46. package/dist/opportunity/application/opportunity.introducer-feature.d.ts +2 -0
  47. package/dist/opportunity/application/opportunity.introducer-feature.js +4 -0
  48. package/dist/opportunity/application/opportunity.owner-approval.d.ts +118 -0
  49. package/dist/opportunity/application/opportunity.owner-approval.js +34 -0
  50. package/dist/opportunity/application/opportunity.owner-provenance.d.ts +6 -0
  51. package/dist/opportunity/application/opportunity.owner-provenance.js +28 -0
  52. package/dist/opportunity/application/opportunity.tools.d.ts +3 -3
  53. package/dist/opportunity/application/opportunity.tools.js +77 -870
  54. package/dist/opportunity/discriminator/discriminator.shadow.js +17 -10
  55. package/dist/opportunity/discriminator/discriminator.types.d.ts +2 -0
  56. package/dist/opportunity/domain/index.d.ts +0 -2
  57. package/dist/opportunity/domain/index.js +0 -2
  58. package/dist/opportunity/domain/opportunity.state.d.ts +2 -70
  59. package/dist/opportunity/domain/opportunity.state.js +2 -55
  60. package/dist/opportunity/opportunity.owner-approval.d.ts +5 -0
  61. package/dist/opportunity/opportunity.owner-approval.js +5 -0
  62. package/dist/opportunity/ports/opportunity.tools.port.d.ts +12 -1
  63. package/dist/opportunity/public/index.d.ts +3 -3
  64. package/dist/opportunity/public/index.js +2 -1
  65. package/dist/participant-agents/application/agent.tools.d.ts +1 -1
  66. package/dist/participant-agents/application/agent.tools.js +44 -6
  67. package/dist/participant-agents/application/index.d.ts +5 -3
  68. package/dist/participant-agents/application/index.js +5 -3
  69. package/dist/questions/application/question.tools.js +31 -2
  70. package/dist/runtime/foreground/composition/tool.factory.d.ts +2 -1
  71. package/dist/runtime/foreground/composition/tool.factory.js +10 -2
  72. package/dist/runtime/foreground/composition/tool.registry.d.ts +16 -2
  73. package/dist/runtime/foreground/composition/tool.registry.js +41 -38
  74. package/dist/shared/agent/activity-projection.d.ts +271 -0
  75. package/dist/shared/agent/activity-projection.js +248 -0
  76. package/dist/shared/agent/canonical-guidance.d.ts +30 -0
  77. package/dist/shared/agent/canonical-guidance.js +249 -0
  78. package/dist/shared/agent/tool.helpers.d.ts +13 -17
  79. package/dist/shared/agent/tool.runtime.js +1 -3
  80. package/dist/shared/agent/utility.tools.d.ts +11 -1
  81. package/dist/shared/agent/utility.tools.js +126 -90
  82. package/dist/shared/interfaces/auth.interface.d.ts +2 -8
  83. package/dist/shared/interfaces/chat-session.interface.js +5 -0
  84. package/dist/shared/interfaces/database.interface.d.ts +12 -5
  85. package/dist/shared/observability/request-context.d.ts +4 -40
  86. package/dist/shared/schemas/mcp-auth.schema.d.ts +87 -15
  87. package/dist/shared/schemas/mcp-auth.schema.js +49 -1
  88. package/dist/signals/application/intent.graph.js +4 -15
  89. package/dist/signals/application/intent.tools.js +34 -14
  90. package/dist/signals/domain/intent.proposal.d.ts +35 -0
  91. package/dist/signals/domain/intent.proposal.js +12 -0
  92. package/package.json +10 -9
  93. package/dist/opportunity/application/opportunity.discover.d.ts +0 -215
  94. package/dist/opportunity/application/opportunity.discover.js +0 -793
  95. package/dist/opportunity/application/opportunity.discovery-continuation-finalization.d.ts +0 -34
  96. package/dist/opportunity/application/opportunity.discovery-continuation-finalization.js +0 -74
  97. package/dist/opportunity/application/opportunity.discovery-mcp-lifecycle-finalization.d.ts +0 -44
  98. package/dist/opportunity/application/opportunity.discovery-mcp-lifecycle-finalization.js +0 -92
  99. package/dist/opportunity/domain/opportunity.discovery-run-coalescing.d.ts +0 -33
  100. package/dist/opportunity/domain/opportunity.discovery-run-coalescing.js +0 -33
  101. package/dist/opportunity/domain/opportunity.discovery.contracts.d.ts +0 -45
  102. package/dist/opportunity/domain/opportunity.discovery.contracts.js +0 -1
  103. package/dist/opportunity/opportunity.discover.d.ts +0 -5
  104. package/dist/opportunity/opportunity.discover.js +0 -5
  105. package/dist/opportunity/opportunity.discovery-continuation-finalization.d.ts +0 -5
  106. package/dist/opportunity/opportunity.discovery-continuation-finalization.js +0 -5
  107. package/dist/opportunity/opportunity.discovery-mcp-lifecycle-finalization.d.ts +0 -5
  108. package/dist/opportunity/opportunity.discovery-mcp-lifecycle-finalization.js +0 -5
  109. package/dist/opportunity/opportunity.discovery-run-coalescing.d.ts +0 -5
  110. package/dist/opportunity/opportunity.discovery-run-coalescing.js +0 -5
  111. package/dist/opportunity/opportunity.discovery.contracts.d.ts +0 -5
  112. package/dist/opportunity/opportunity.discovery.contracts.js +0 -5
  113. package/dist/shared/interfaces/discovery-run.interface.d.ts +0 -70
  114. package/dist/shared/interfaces/discovery-run.interface.js +0 -1
@@ -1,18 +1,17 @@
1
1
  import { z } from "zod";
2
2
  import { requestContext } from "../../shared/observability/request-context.js";
3
3
  import { success, error, UUID_REGEX } from "../../shared/agent/tool.helpers.js";
4
- import { deriveDiscoveryNetworkIds, focusedIntentId, focusedNetworkId, focusedNetworkLabel } from "../../shared/agent/tool.scope.js";
4
+ import { focusedIntentId, focusedNetworkId, focusedNetworkLabel } from "../../shared/agent/tool.scope.js";
5
5
  import { MINIMAL_MAIN_TEXT_MAX_CHARS, getPrimaryActionLabel, SECONDARY_ACTION_LABEL } from "../domain/opportunity.labels.js";
6
6
  import { narratorRemarkFromReasoning, stripUuids } from "../domain/opportunity.presentation.js";
7
- import { safeFallbackSummary, getSafePresentationOrSkip } from "../domain/opportunity.safe-presentation.js";
7
+ import { safeFallbackSummary } from "../domain/opportunity.safe-presentation.js";
8
8
  import { buildOpportunityPresentation } from "./opportunity.card-presentation.js";
9
- import { findCoalescedDiscoveryRun } from "../domain/opportunity.discovery-run-coalescing.js";
10
- import { finalizeMcpDiscoveryLifecycle } from './opportunity.discovery-mcp-lifecycle-finalization.js';
11
- import { runDiscoverFromQuery, continueDiscovery } from "./opportunity.discover.js";
12
- import { isDiscoveryQuestionsEnabled, isUptakeGuardEnabled } from "../../capabilities/questions.runtime.facade.js";
9
+ import { isUptakeGuardEnabled } from "../../capabilities/questions.runtime.facade.js";
13
10
  import { OpportunityPresenter, gatherPresenterContext } from "./opportunity.presenter.js";
14
11
  import { loadNegotiationContext } from "./negotiation-context.loader.js";
15
12
  import { admitOpportunityUpdate } from './opportunity.update-admission.js';
13
+ import { opportunityOwnerActionForStatus } from './opportunity.owner-approval.js';
14
+ import { ownerApprovalProvenanceFor } from './opportunity.owner-provenance.js';
16
15
  import { selectOpportunityFeed } from './opportunity.feed-selection.js';
17
16
  export { buildOpportunityPresentation } from "./opportunity.card-presentation.js";
18
17
  function stripLeadingNarratorName(remark, narratorName) {
@@ -40,10 +39,8 @@ function stripLeadingNarratorName(remark, narratorName) {
40
39
  return t;
41
40
  }
42
41
  import { protocolLogger } from "../../shared/observability/protocol.logger.js";
43
- import { mergePendingQuestions } from "./opportunity.pending-questions.js";
44
42
  import { invokeWithAbortSignal } from "../../shared/agent/model-signal.js";
45
43
  const logger = protocolLogger("ChatTools:Opportunity");
46
- const discoverOpportunitiesLog = protocolLogger("ChatTools:Opportunity:discover_opportunities");
47
44
  /**
48
45
  * Pure status × role → ConnectLinkKind matrix.
49
46
  *
@@ -54,14 +51,14 @@ const discoverOpportunitiesLog = protocolLogger("ChatTools:Opportunity:discover_
54
51
  * flips the opp to accepted and opens the chat with the counterpart.
55
52
  * - `approve_introduction` — draft or latent opp where viewer is an unapproved
56
53
  * introducer. Clicking flips approved=true and triggers negotiation. The
57
- * `draft` case comes from `discover_opportunities` intro mode; the `latent`
54
+ * `draft` case represents a pending introduction; the `latent`
58
55
  * case comes from background-discovered connector-flow cards surfaced in
59
56
  * `list_opportunities`. In both, status remains pre-send and the `/c/<code>`
60
57
  * link is the only MCP path to approve.
61
58
  * - `outreach` — accepted opp where viewer is a non-introducer party.
62
59
  * Clicking opens the existing chat (no state change).
63
60
  * - `send_direct` — draft or latent opp where viewer is a non-introducer
64
- * party. Issued by `discover_opportunities` in direct (no-introducer)
61
+ * party. Issued for a direct opportunity without an introducer
65
62
  * mode: the match has already passed evaluation, the row exists in
66
63
  * draft state, and the sender just needs to release it. Clicking flips
67
64
  * the opp straight to accepted and opens the chat with a greeting —
@@ -135,7 +132,7 @@ export async function attachActionableLinks(card, opts) {
135
132
  // is actionable — every counterpart has a profile page worth linking to,
136
133
  // even on a fresh draft where there is no acceptUrl yet. Setting it before
137
134
  // the early-return below means cards from non-actionable combinations
138
- // (e.g. draft + party in `discover_opportunities` direct mode) still carry
135
+ // (e.g. a draft party card) still carry
139
136
  // the profile link the agent needs to render. Without this, the agent gets
140
137
  // a name with no URL attached and tends to fabricate one.
141
138
  const profileUrl = buildProfileUrl(opts.counterpartUserId, opts.frontendUrl);
@@ -198,6 +195,26 @@ function uptakeAdvisory(opportunityId, questions) {
198
195
  },
199
196
  });
200
197
  }
198
+ /**
199
+ * IND-593: stable fail-closed denial for the owner-approval boundary. The
200
+ * `missing` reason carries the fresh, server-derived interaction challenge the
201
+ * owner must explicitly approve; all other reasons carry no challenge.
202
+ */
203
+ function ownerApprovalDenial(opportunityId, action, verdict) {
204
+ return JSON.stringify({
205
+ success: false,
206
+ error: `Owner approval required for this opportunity ${action} (${verdict.reason}).`,
207
+ approval: {
208
+ code: "owner_approval_required",
209
+ reason: verdict.reason,
210
+ opportunityId,
211
+ action,
212
+ ...(verdict.challenge
213
+ ? { interactionId: verdict.challenge.interactionId, expiresAt: verdict.challenge.expiresAt }
214
+ : {}),
215
+ },
216
+ });
217
+ }
201
218
  /**
202
219
  * Maximum number of opportunity cards to show per chat response.
203
220
  * Sized for `selectByComposition` to fill both feed buckets — up to 3
@@ -280,861 +297,10 @@ function confirmDeliveryError(code, retryable, message) {
280
297
  }
281
298
  export function createOpportunityTools(defineTool, deps) {
282
299
  const { database, userDb, systemDb, graphs, cache } = deps;
283
- const runDiscoveryFromQuery = deps.opportunityDiscovery?.runDiscoverFromQuery ??
284
- runDiscoverFromQuery;
285
- const continueOpportunityDiscovery = deps.opportunityDiscovery?.continueDiscovery ??
286
- continueDiscovery;
287
300
  const createOpportunityPresenter = deps.opportunityPresentation?.createPresenter ??
288
301
  (() => new OpportunityPresenter());
289
302
  const gatherOpportunityPresenterContext = deps.opportunityPresentation?.gatherPresenterContext ??
290
303
  gatherPresenterContext;
291
- const discoverOpportunities = defineTool({
292
- name: "discover_opportunities",
293
- description: "Discovers opportunities — connections between users based on complementary intents — and persists them as drafts. " +
294
- "Opportunities are the core output of the discovery engine, representing potential valuable connections between people.\n\n" +
295
- "**NOT for person lookup** — use read_user_contexts(query=name) to find people by name.\n\n" +
296
- "**Four modes:**\n" +
297
- "1. **Discovery** (most common): pass `searchQuery` and/or `networkId`. The system finds other users in shared networkes " +
298
- "whose intents semantically complement the query. Uses HyDE embeddings and LLM evaluation for scoring.\n" +
299
- "2. **Introduction**: pass `partyUserIds` (2+ user IDs) + `entities` (pre-gathered profiles and intents from shared networkes). " +
300
- "You MUST call read_user_contexts and read_intents for each party BEFORE calling this. " +
301
- "Optionally pass `hint` with the user's reason for the introduction.\n" +
302
- "3. **Direct connection**: pass `targetUserId` + `searchQuery`. Creates an opportunity between the current user and one specific person.\n" +
303
- "4. **Introducer discovery**: pass `introTargetUserId` (find matches FOR that person; current user becomes the introducer). " +
304
- "Use when user asks 'who should I introduce to [person]?'\n\n" +
305
- "**Returns:** In regular chat, opportunity code blocks (render as interactive cards) with opportunityId, match reasoning, confidence score, and status. " +
306
- "In MCP contexts, starts an async discovery run and returns `discoveryRunId` with status `queued`. " +
307
- "Then poll get_discovery_run with that id roughly every 5 seconds until status is `succeeded`, `failed`, or `cancelled`, and present its `result`. " +
308
- "Do NOT call discover_opportunities again for the same request while a run is in progress — a repeat call with the same parameters " +
309
- "returns the SAME in-progress run (with `coalesced: true`), not a new one. Keep polling the run id instead of starting new runs. " +
310
- "All results start as drafts. Supports pagination via `continueFrom` for large result sets.\n\n" +
311
- "**Next steps:** Use update_opportunity(opportunityId, status='pending') to send a draft to the other party.\n\n" +
312
- "**Discovery-first rule.** For open-ended connection-seeking requests (\"find me a mentor\", " +
313
- "\"who needs a React dev\", \"looking for investors\"), call this tool with `searchQuery` FIRST. " +
314
- "Do NOT call create_intent for these phrasings — create_intent is only for when the user explicitly " +
315
- "asks to \"create\", \"save\", \"add\", or \"remember\" a signal.\n\n" +
316
- "**Personal-index scoping.** When the user says \"in my network\", \"from my contacts\", \"people I know\", " +
317
- "or similar scoping language, pass the user's personal network ID (from memberships where `isPersonal: true`) " +
318
- "as `networkId`. The personal network contains the user's contacts — scoping discovery to it restricts " +
319
- "results to people the user already knows. Without this scoping language, omit networkId to let discovery " +
320
- "run across all networks.\n\n" +
321
- "**Introduction mode prerequisites.** When using `partyUserIds` + `entities`, YOU must pre-fetch each party's " +
322
- "profile and intents before calling this tool. The entities array must include each party's userId, profile, " +
323
- "intents from shared networkes, and the shared networkId. Call read_user_contexts, read_network_memberships, " +
324
- "and read_intents for both parties first. The introducer (current user) must NOT appear in entities.\n\n" +
325
- "**Signal-visibility follow-up.** If the response includes `suggestIntentCreationForVisibility: true` and " +
326
- "`suggestedIntentDescription`, after presenting opportunity cards ask the user ONCE whether they'd also like " +
327
- "to create a signal so others can find them. On yes, call create_intent with the suggested description. " +
328
- "Never suggest this after introducer-mode (`introTargetUserId`) calls — the query describes the other person's " +
329
- "needs, not the signed-in user's.",
330
- querySchema: z.object({
331
- continueFrom: z
332
- .string()
333
- .optional()
334
- .describe("Pagination token: pass the discoveryId from a previous discover_opportunities result to evaluate the next batch of candidates. Do not combine with searchQuery or other mode parameters — when a fresh searchQuery is also present, the server ignores continueFrom and runs a fresh discovery."),
335
- searchQuery: z
336
- .string()
337
- .optional()
338
- .describe("Discovery mode: natural language description of what to look for (e.g. 'AI/ML engineers', 'startup advisors in fintech'). Drives semantic matching against other users' intents and profiles."),
339
- networkId: z
340
- .string()
341
- .optional()
342
- .describe("Network UUID to scope discovery to a specific community. Get from read_networks. In an network-scoped chat, omitting this runs discovery only in the scoped community; pass the personal network ID (from read_networks, isPersonal=true) only when the user explicitly asks to discover among contacts."),
343
- intentId: z
344
- .string()
345
- .optional()
346
- .describe("Optional intent UUID to use as the discovery source. The intent's description drives matching instead of searchQuery. Get from read_intents. Typically used by background processing, not direct agent calls."),
347
- targetUserId: z
348
- .string()
349
- .optional()
350
- .describe("Direct connection mode: create an opportunity with this specific user. Get the userId from read_user_contexts(query=name). Combine with searchQuery to explain the connection reason."),
351
- introTargetUserId: z
352
- .string()
353
- .optional()
354
- .describe("Introducer discovery mode: find matches FOR this user ID (the current user becomes the introducer). " +
355
- "Get the userId from read_user_contexts(query=name). " +
356
- "Use when the user asks 'who should I introduce to [person]?'. " +
357
- "Do NOT combine with partyUserIds (that's full introduction mode)."),
358
- partyUserIds: z
359
- .array(z.string())
360
- .optional()
361
- .describe("Introduction mode: array of 2+ user IDs to introduce to each other. Get user IDs from read_user_contexts or read_network_memberships. Must also provide entities with pre-gathered profile/intent data."),
362
- entities: z
363
- .array(z.object({
364
- userId: z.string(),
365
- profile: z
366
- .object({
367
- name: z.string().optional(),
368
- bio: z.string().optional(),
369
- location: z.string().optional(),
370
- interests: z.array(z.string()).optional(),
371
- skills: z.array(z.string()).optional(),
372
- context: z.string().optional(),
373
- })
374
- .optional(),
375
- intents: z
376
- .array(z.object({
377
- intentId: z.string(),
378
- payload: z.string(),
379
- summary: z.string().optional(),
380
- }))
381
- .optional(),
382
- networkId: z
383
- .string()
384
- .describe("Shared network this entity's data comes from (required for intro mode)"),
385
- }))
386
- .optional()
387
- .describe("Introduction mode: pre-gathered profile and intent data for each party being introduced. " +
388
- "Each entry needs userId, networkId (the shared network), and optionally profile (name, bio, skills, interests) and intents (intentId, payload). " +
389
- "Gather this data by calling read_user_contexts and read_intents for each party BEFORE calling discover_opportunities. " +
390
- "All entities must share the same networkId (the shared network where both parties are members)."),
391
- hint: z
392
- .string()
393
- .optional()
394
- .describe("Introduction mode: the user's reason for making this introduction (e.g. 'both working on AI in healthcare', " +
395
- "'complementary skills for a startup'). Helps the evaluator produce better match reasoning."),
396
- }),
397
- handler: async ({ context, query }) => {
398
- const scopedNetworkId = focusedNetworkId(context) ?? context.networkId?.trim();
399
- const scopedIndexLabel = focusedNetworkLabel(context);
400
- // Strict scope enforcement: when chat is network-scoped, only allow that index
401
- if (scopedNetworkId &&
402
- query.networkId?.trim() &&
403
- query.networkId.trim() !== scopedNetworkId) {
404
- return error(`This chat is scoped to ${scopedIndexLabel}. You can only create opportunities in this community.`);
405
- }
406
- // Distinguish an explicit `query.networkId` override (caller wants discovery
407
- // scoped to one specific index) from an implicit scoped-chat context.
408
- // Scoped-chat discovery stays focused to the scoped community only; the
409
- // personal-inclusive allowed reach is reserved for self-owned writes.
410
- const explicitIndexId = query.networkId?.trim() || undefined;
411
- const effectiveIndexId = explicitIndexId;
412
- if (effectiveIndexId && !UUID_REGEX.test(effectiveIndexId)) {
413
- return error("Invalid network ID format.");
414
- }
415
- const contextIntentId = focusedIntentId(context);
416
- const requestedIntentId = query.intentId?.trim() || undefined;
417
- if (requestedIntentId != null && !UUID_REGEX.test(requestedIntentId)) {
418
- return error("Invalid intent ID format.");
419
- }
420
- if (contextIntentId && requestedIntentId && requestedIntentId !== contextIntentId) {
421
- return error("This chat is scoped to a different intent.");
422
- }
423
- const triggerIntentId = contextIntentId ?? requestedIntentId;
424
- if (triggerIntentId) {
425
- const triggerIntent = await systemDb.getIntent(triggerIntentId);
426
- if (!triggerIntent || triggerIntent.userId !== context.userId) {
427
- return error("Intent not found or you are not authorized to use it for discovery.");
428
- }
429
- if (triggerIntent.archivedAt ||
430
- (triggerIntent.status != null && triggerIntent.status !== 'ACTIVE')) {
431
- return error("This intent is not active. Resume it before starting discovery.");
432
- }
433
- }
434
- if (context.isMcp && deps.discoveryRuns && deps.discoveryRunQueue) {
435
- // Coalesce: if an equivalent discovery is already queued/running for this
436
- // user, return that run instead of spawning a duplicate. An over-eager
437
- // MCP client that re-fires discover_opportunities (instead of polling
438
- // get_discovery_run) would otherwise kick off a fresh, expensive
439
- // opportunity-graph run on every call — the loop that drives the agent
440
- // into provider rate limits.
441
- try {
442
- const active = await deps.discoveryRuns.listActive(context.userId);
443
- const existing = findCoalescedDiscoveryRun(query, context, active);
444
- if (existing) {
445
- return success({
446
- status: existing.status === "running" ? "running" : "queued",
447
- discoveryRunId: existing.id,
448
- coalesced: true,
449
- message: `A discovery run for this exact request is already ${existing.status}. ` +
450
- `Do NOT call discover_opportunities again — keep calling get_discovery_run with ` +
451
- `discoveryRunId="${existing.id}" (about every 5 seconds) until it succeeds, fails, ` +
452
- `or is cancelled, then present its result.`,
453
- });
454
- }
455
- }
456
- catch {
457
- // listActive is a best-effort optimization; fall through to create on error.
458
- }
459
- const run = await deps.discoveryRuns.create({
460
- userId: context.userId,
461
- agentId: context.agentId ?? null,
462
- input: query,
463
- context: {
464
- userId: context.userId,
465
- userName: context.userName,
466
- userEmail: context.userEmail,
467
- ...(context.scopeType && context.scopeId ? { scopeType: context.scopeType, scopeId: context.scopeId } : scopedNetworkId ? { scopeType: 'network', scopeId: scopedNetworkId } : {}),
468
- ...(context.indexName ? { indexName: context.indexName } : {}),
469
- ...(context.sessionId ? { sessionId: context.sessionId } : {}),
470
- ...(context.agentId ? { agentId: context.agentId } : {}),
471
- ...(context.clientSurface ? { clientSurface: context.clientSurface } : {}),
472
- },
473
- });
474
- try {
475
- await deps.discoveryRunQueue.enqueue(run.id);
476
- }
477
- catch (err) {
478
- const message = err instanceof Error ? err.message : String(err);
479
- await deps.discoveryRuns.markFailed(run.id, message);
480
- return error(`Failed to enqueue discovery run: ${message}`);
481
- }
482
- return success({
483
- status: "queued",
484
- discoveryRunId: run.id,
485
- message: `Discovery started. Poll get_discovery_run with discoveryRunId="${run.id}" about every 5 seconds ` +
486
- `until status is succeeded, failed, or cancelled, then present its result. ` +
487
- `Do NOT call discover_opportunities again for this request while the run is in progress — ` +
488
- `a repeat call returns this same run, not a new one.`,
489
- });
490
- }
491
- // ── Continuation mode ──
492
- // `continueFrom` is a pagination token for resuming a prior discovery's
493
- // cached candidates. When a caller (typically an MCP client's LLM) sends
494
- // a fresh `searchQuery` alongside a stale `continueFrom`, treat it as a
495
- // fresh search — the explicit search intent wins. Resuming against the
496
- // stale session's exhausted cache silently produced the "No more
497
- // matching opportunities found in the remaining candidates" response
498
- // for users who expected fresh results (IND-305).
499
- if (query.continueFrom && query.searchQuery?.trim()) {
500
- logger.warn("discover_opportunities: dropping stale continueFrom in favor of fresh searchQuery", {
501
- userId: context.userId,
502
- continueFrom: query.continueFrom,
503
- });
504
- }
505
- if (query.continueFrom && !query.searchQuery?.trim()) {
506
- const _continueTraceEmitter = requestContext.getStore()?.traceEmitter;
507
- const _graphStart = Date.now();
508
- _continueTraceEmitter?.({ type: "graph_start", name: "opportunity" });
509
- const result = await continueOpportunityDiscovery({
510
- opportunityGraph: graphs.opportunity,
511
- database,
512
- cache,
513
- userId: context.userId,
514
- discoveryId: query.continueFrom,
515
- expectedIndexId: scopedNetworkId,
516
- limit: 20,
517
- presenter: createOpportunityPresenter(),
518
- useHomeCardFormat: true,
519
- ...(context.sessionId ? { chatSessionId: context.sessionId } : {}),
520
- });
521
- const _graphMs = Date.now() - _graphStart;
522
- _continueTraceEmitter?.({ type: "graph_end", name: "opportunity", durationMs: _graphMs });
523
- const allDebugSteps = [...(result.debugSteps ?? [])];
524
- if (!result.found) {
525
- return success({
526
- found: false,
527
- count: 0,
528
- message: result.message ?? "No more matching opportunities found in the remaining candidates.",
529
- summary: "No more matches found",
530
- ...(result.pagination ? { pagination: result.pagination } : {}),
531
- debugSteps: allDebugSteps,
532
- _graphTimings: [{ name: 'opportunity', durationMs: _graphMs, agents: [] }],
533
- });
534
- }
535
- // Continuation now negotiates just like fresh discovery. For MCP callers,
536
- // surface only post-negotiation drafts; keep in-flight attempts count-only
537
- // and never render rejected/stalled or fallback latent rows as cards.
538
- let negotiatingCount = 0;
539
- const continuationCards = context.isMcp
540
- ? (result.opportunities ?? []).filter((opportunity) => {
541
- if (opportunity.status === 'draft')
542
- return true;
543
- if (opportunity.status === 'negotiating') {
544
- negotiatingCount += 1;
545
- return false;
546
- }
547
- if (opportunity.status === 'rejected' || opportunity.status === 'stalled' || opportunity.status === 'latent') {
548
- return false;
549
- }
550
- discoverOpportunitiesLog.warn('unexpected continuation status — counting as negotiating', {
551
- opportunityId: opportunity.opportunityId,
552
- status: opportunity.status,
553
- });
554
- negotiatingCount += 1;
555
- return false;
556
- })
557
- : (result.opportunities ?? []);
558
- // Build card data; cap at CHAT_DISPLAY_LIMIT (remaining feeds into pagination)
559
- const allCardData = continuationCards.map((opp) => ({
560
- opportunityId: opp.opportunityId,
561
- userId: opp.userId,
562
- name: opp.name,
563
- avatar: opp.avatar,
564
- mainText: getSafePresentationOrSkip(opp, { counterpartName: opp.name })?.summary ?? "",
565
- cta: opp.homeCardPresentation?.suggestedAction,
566
- headline: opp.homeCardPresentation?.headline,
567
- primaryActionLabel: opp.homeCardPresentation?.primaryActionLabel,
568
- secondaryActionLabel: opp.homeCardPresentation?.secondaryActionLabel,
569
- mutualIntentsLabel: opp.homeCardPresentation?.mutualIntentsLabel,
570
- narratorChip: opp.narratorChip,
571
- viewerRole: opp.viewerRole,
572
- isGhost: opp.isGhost ?? false,
573
- score: opp.score,
574
- status: opp.status,
575
- viewerActedAt: opp.viewerActedAt,
576
- }));
577
- const displayedCards = allCardData.slice(0, CHAT_DISPLAY_LIMIT);
578
- const extraFromCap = allCardData.length - displayedCards.length;
579
- let message = buildOpportunityPresentation(displayedCards, {
580
- isMcp: context.isMcp ?? false,
581
- leadIn: `Found ${displayedCards.length} more potential connection(s).`,
582
- });
583
- if (context.isMcp && negotiatingCount > 0) {
584
- message = displayedCards.length > 0
585
- ? `${message}\n\n${negotiatingCount} more opportunit${negotiatingCount === 1 ? 'y is' : 'ies are'} still being evaluated — check back via \`list_opportunities\` shortly.`
586
- : `Found candidates, but they're still being evaluated. Try \`list_opportunities\` in a minute — ${negotiatingCount} pending.`;
587
- }
588
- else if (context.isMcp && displayedCards.length === 0) {
589
- message = 'No additional actionable matches were found in this continuation.';
590
- }
591
- const isIntroducerContinuation = !!query.introTargetUserId?.trim();
592
- const totalRemaining = (result.pagination?.remaining ?? 0) + extraFromCap;
593
- if (totalRemaining > 0 && result.pagination?.discoveryId) {
594
- message += `\n\nThere are ${totalRemaining} more candidates. Ask if the user wants to see more — they can say "show me more" and you should call discover_opportunities with continueFrom="${result.pagination.discoveryId}".`;
595
- }
596
- else if (displayedCards.length > 0 && isIntroducerContinuation) {
597
- message += `\n\nThese are all the introduction candidates I found for this person.`;
598
- }
599
- else if (displayedCards.length > 0) {
600
- message += `\n\nThese are all the connections I found. If the user wants to attract more connections, suggest they create a signal — e.g. "Would you like to create a signal so others looking for someone like you can find you?" If they agree, call create_intent with a description based on what they were searching for.`;
601
- }
602
- return success({
603
- found: true,
604
- count: displayedCards.length,
605
- message,
606
- summary: `Found ${displayedCards.length} more match(es)`,
607
- ...(result.pagination ? { pagination: result.pagination } : {}),
608
- debugSteps: allDebugSteps,
609
- _graphTimings: [{ name: 'opportunity', durationMs: _graphMs, agents: [] }],
610
- });
611
- }
612
- // Normalize entity networkIds before any checks to avoid raw-vs-trimmed mismatches.
613
- const normalizedEntities = query.entities?.map((e) => ({ ...e, networkId: e.networkId?.trim() }));
614
- // Derive partyUserIds from entities when agent passes entities but omits partyUserIds (intro mode).
615
- // Only derive when all entities share the same networkId to prevent cross-network introductions.
616
- const partyUserIdsFromEntities = normalizedEntities &&
617
- normalizedEntities.length >= 2 &&
618
- normalizedEntities.every((e) => e.userId && e.networkId) &&
619
- new Set(normalizedEntities.map((e) => e.networkId)).size === 1
620
- ? [...new Set(normalizedEntities.map((e) => e.userId))]
621
- : undefined;
622
- const effectivePartyUserIds = query.partyUserIds && query.partyUserIds.length >= 2
623
- ? query.partyUserIds
624
- : (partyUserIdsFromEntities?.length ?? 0) >= 2
625
- ? partyUserIdsFromEntities
626
- : undefined;
627
- // ── Introduction mode ── (validation and persistence via opportunity graph)
628
- if (effectivePartyUserIds && effectivePartyUserIds.length >= 2) {
629
- if (!normalizedEntities || normalizedEntities.length === 0) {
630
- return error("Introduction requires pre-gathered entity data. " +
631
- "First use read_network_memberships to find shared networks, " +
632
- "then read_user_contexts and read_intents for each party, " +
633
- "then pass the results as entities.");
634
- }
635
- const normalizedEntityNetworkIds = normalizedEntities
636
- .map((e) => e.networkId)
637
- .filter((id) => Boolean(id));
638
- if (normalizedEntityNetworkIds.length !== normalizedEntities.length ||
639
- new Set(normalizedEntityNetworkIds).size !== 1) {
640
- return error("All entities must include the same shared networkId.");
641
- }
642
- const [primaryNetworkId] = normalizedEntityNetworkIds;
643
- const introducedPartyUserIds = effectivePartyUserIds.filter((uid) => uid !== context.userId);
644
- if (introducedPartyUserIds.length === 0) {
645
- return error("No counterpart to introduce. Provide at least one other user ID in partyUserIds (besides yourself).");
646
- }
647
- const evaluatorEntities = normalizedEntities.map((e) => ({
648
- userId: e.userId,
649
- profile: e.profile ?? {},
650
- intents: e.intents,
651
- networkId: e.networkId,
652
- }));
653
- const _introGraphStart = Date.now();
654
- const _introTraceEmitter = requestContext.getStore()?.traceEmitter;
655
- _introTraceEmitter?.({ type: "graph_start", name: "opportunity" });
656
- const result = await invokeWithAbortSignal(graphs.opportunity, {
657
- operationMode: "create_introduction",
658
- userId: context.userId,
659
- networkId: primaryNetworkId,
660
- introductionEntities: evaluatorEntities,
661
- introductionHint: query.hint,
662
- requiredNetworkId: scopedNetworkId,
663
- options: {
664
- initialStatus: "draft",
665
- ...(context.sessionId ? { conversationId: context.sessionId } : {}),
666
- },
667
- });
668
- const _introGraphMs = Date.now() - _introGraphStart;
669
- _introTraceEmitter?.({ type: "graph_end", name: "opportunity", durationMs: _introGraphMs });
670
- if (result.error || !result.opportunities?.length) {
671
- return error(result.error ?? "Failed to create introduction.");
672
- }
673
- const created = result.opportunities[0];
674
- const reasoning = created.interpretation?.reasoning ?? "A suggested connection.";
675
- const confidence = typeof created.interpretation?.confidence === "number"
676
- ? created.interpretation.confidence
677
- : parseFloat(String(created.confidence ?? 0)) || 0;
678
- const introducerUser = await userDb.getUser();
679
- const firstPartyId = introducedPartyUserIds[0];
680
- const firstEntity = query.entities?.find((e) => e.userId === firstPartyId);
681
- const counterpartUser = firstPartyId
682
- ? await database.getUser(firstPartyId)
683
- : null;
684
- const counterpartName = firstEntity?.profile?.name ?? firstPartyId ?? "Someone";
685
- // Second party — used in the headline and arrow layout for the introducer view ("A → B")
686
- const secondPartyId = introducedPartyUserIds[1];
687
- const secondEntity = query.entities?.find((e) => e.userId === secondPartyId);
688
- const secondPartyName = secondEntity?.profile?.name;
689
- const secondPartyAvatar = secondEntity?.profile?.avatar ?? null;
690
- const secondPartyUser = secondPartyId ? await database.getUser(secondPartyId) : null;
691
- const viewerIsParty = effectivePartyUserIds.includes(context.userId);
692
- const viewerRole = viewerIsParty ? "party" : "introducer";
693
- const isCounterpartGhost = counterpartUser?.isGhost ?? false;
694
- const primaryActionLabel = getPrimaryActionLabel(viewerRole);
695
- const publicMatchReason = safeFallbackSummary(reasoning, {
696
- counterpartName,
697
- introducerName: introducerUser?.name ?? undefined,
698
- maxChars: MINIMAL_MAIN_TEXT_MAX_CHARS,
699
- emptyText: "A suggested connection.",
700
- });
701
- const narratorText = narratorRemarkFromReasoning(reasoning, counterpartName, introducerUser?.name ?? undefined);
702
- const narratorChip = viewerIsParty
703
- ? {
704
- name: "Index",
705
- text: narratorText,
706
- }
707
- : {
708
- name: "You",
709
- text: narratorText,
710
- userId: context.userId,
711
- };
712
- const headline = !viewerIsParty && secondPartyName
713
- ? `${counterpartName} → ${secondPartyName}`
714
- : `Connection with ${counterpartName}`;
715
- const cardData = {
716
- opportunityId: created.id,
717
- userId: firstPartyId,
718
- name: counterpartName,
719
- avatar: counterpartUser?.avatar ??
720
- firstEntity?.profile
721
- ?.avatar ??
722
- null,
723
- mainText: safeFallbackSummary(reasoning, {
724
- counterpartName,
725
- // viewerName not available in this context; introducer name passed separately
726
- introducerName: introducerUser?.name ?? undefined,
727
- maxChars: MINIMAL_MAIN_TEXT_MAX_CHARS,
728
- emptyText: "A suggested connection.",
729
- }),
730
- cta: "Start a conversation to connect.",
731
- headline,
732
- primaryActionLabel,
733
- secondaryActionLabel: SECONDARY_ACTION_LABEL,
734
- mutualIntentsLabel: "Suggested connection",
735
- narratorChip,
736
- viewerRole,
737
- isGhost: isCounterpartGhost,
738
- score: confidence,
739
- status: created.status ?? "draft",
740
- ...(!viewerIsParty && secondPartyName
741
- ? {
742
- secondParty: {
743
- name: secondPartyName,
744
- avatar: secondPartyUser?.avatar ?? secondPartyAvatar,
745
- ...(secondPartyId ? { userId: secondPartyId } : {}),
746
- },
747
- }
748
- : {}),
749
- };
750
- if (context.isMcp && deps.mintConnectLink) {
751
- await attachActionableLinks(cardData, {
752
- viewerId: context.userId,
753
- viewerApproved: false,
754
- counterpartUserId: firstPartyId,
755
- mintConnectLink: deps.mintConnectLink,
756
- frontendUrl: deps.frontendUrl,
757
- preferredSurface: context.clientSurface,
758
- });
759
- }
760
- return success({
761
- found: true,
762
- count: 1,
763
- summary: "Draft introduction created",
764
- message: buildOpportunityPresentation([cardData], {
765
- isMcp: context.isMcp ?? false,
766
- leadIn: "Draft introduction created.",
767
- label: "opportunity",
768
- }),
769
- opportunities: [
770
- {
771
- opportunityId: created.id,
772
- matchReason: publicMatchReason,
773
- score: confidence,
774
- status: created.status ?? "draft",
775
- },
776
- ],
777
- _graphTimings: [{ name: 'opportunity', durationMs: _introGraphMs, agents: result.agentTimings ?? [] }],
778
- });
779
- }
780
- // ── Discovery mode ──
781
- const searchQuery = query.searchQuery?.trim() ?? "";
782
- if (query.intentId != null && query.intentId !== "" && !UUID_REGEX.test(query.intentId.trim())) {
783
- return error("Invalid intent ID format.");
784
- }
785
- let indexScope;
786
- const _scopeGraphTimings = [];
787
- if (effectiveIndexId) {
788
- if (!UUID_REGEX.test(effectiveIndexId)) {
789
- return error("Invalid network ID format.");
790
- }
791
- const _scopeGraphStart = Date.now();
792
- const _scopeIndexMembershipTraceEmitter = requestContext.getStore()?.traceEmitter;
793
- _scopeIndexMembershipTraceEmitter?.({ type: "graph_start", name: "network_membership" });
794
- const memberResult = await invokeWithAbortSignal(graphs.networkMembership, {
795
- userId: context.userId,
796
- networkId: effectiveIndexId,
797
- operationMode: "read",
798
- });
799
- const _scopeIndexMembershipMs = Date.now() - _scopeGraphStart;
800
- _scopeIndexMembershipTraceEmitter?.({ type: "graph_end", name: "network_membership", durationMs: _scopeIndexMembershipMs });
801
- _scopeGraphTimings.push({ name: 'network_membership', durationMs: _scopeIndexMembershipMs, agents: [] });
802
- if (memberResult.error) {
803
- return error("Network not found or you are not a member.");
804
- }
805
- indexScope = [effectiveIndexId];
806
- }
807
- else if (context.scopeType === 'network' && context.scopeId) {
808
- // Scoped chat: discovery is focused-network only. Self-owned writes may
809
- // include personal networkes, but opportunity visibility must not.
810
- const scopedDiscoveryIds = deriveDiscoveryNetworkIds({
811
- memberships: context.userNetworks,
812
- scopeType: context.scopeType,
813
- scopeId: context.scopeId,
814
- });
815
- indexScope = scopedDiscoveryIds;
816
- }
817
- else if (scopedNetworkId) {
818
- // Scoped context: preserve focused-only discovery using the scope envelope.
819
- indexScope = [scopedNetworkId];
820
- }
821
- else {
822
- // No scope - use all networks (only in unscoped chat)
823
- const _scopeGraphStart = Date.now();
824
- const _scopeIndexTraceEmitter = requestContext.getStore()?.traceEmitter;
825
- _scopeIndexTraceEmitter?.({ type: "graph_start", name: "index" });
826
- const indexResult = await invokeWithAbortSignal(graphs.index, {
827
- userId: context.userId,
828
- operationMode: "read",
829
- showAll: true,
830
- });
831
- const _scopeIndexMs = Date.now() - _scopeGraphStart;
832
- _scopeIndexTraceEmitter?.({ type: "graph_end", name: "index", durationMs: _scopeIndexMs });
833
- _scopeGraphTimings.push({ name: 'index', durationMs: _scopeIndexMs, agents: [] });
834
- if (indexResult.error) {
835
- return error(indexResult.error);
836
- }
837
- indexScope = (indexResult.readResult?.memberOf || []).map((m) => m.networkId);
838
- }
839
- const toolDebugSteps = [
840
- { step: "resolve_index_scope", detail: `${indexScope.length} index(es)` },
841
- ];
842
- if (query.introTargetUserId?.trim() && query.introTargetUserId.trim() === context.userId) {
843
- return error("You cannot discover introductions for yourself. Try regular discovery instead.");
844
- }
845
- const _discoverTraceEmitter = requestContext.getStore()?.traceEmitter;
846
- const _discoverGraphStart = Date.now();
847
- _discoverTraceEmitter?.({ type: "graph_start", name: "opportunity" });
848
- // Chat-driven invocations run under the orchestrator trigger: persist
849
- // opens at 'negotiating', negotiate fans out with a 60s park window,
850
- // each accepted draft streams via traceEmitter, and the persist step
851
- // surfaces already-accepted pairs. Other callers (maintenance, queue
852
- // workers) still get the 'ambient' default.
853
- // Orchestrator trigger fires for both web chat (has sessionId) and MCP
854
- // (isMcp=true, no sessionId). Both are user-initiated discovery that
855
- // should persist as `negotiating` and flip to `draft` post-finalize via
856
- // onCandidateResolved. Ambient/cron paths leave both falsy and use the
857
- // `pending` default.
858
- const runDiscoveryOrchestrator = !!context.sessionId || !!context.isMcp;
859
- const result = await runDiscoveryFromQuery({
860
- opportunityGraph: graphs.opportunity,
861
- database,
862
- userId: context.userId,
863
- query: searchQuery,
864
- indexScope,
865
- limit: 20,
866
- presenter: createOpportunityPresenter(),
867
- useHomeCardFormat: true,
868
- triggerIntentId,
869
- targetUserId: query.targetUserId?.trim() || undefined,
870
- onBehalfOfUserId: query.introTargetUserId?.trim() || undefined,
871
- cache,
872
- // MCP-only: cap the negotiate phase at 20 s so Railway's edge proxy
873
- // (which 502s the client at ~57 s) never beats the response. The
874
- // remainder finalizes in the background and is fetched on the
875
- // user's next list_opportunities call. Removable when IND-274
876
- // (negotiation conversation continuation) lands.
877
- ...(context.isMcp ? { negotiateTimeoutMs: 20000 } : {}),
878
- ...(context.sessionId ? { chatSessionId: context.sessionId } : {}),
879
- ...(runDiscoveryOrchestrator && { trigger: 'orchestrator' }),
880
- ...(deps.chatSummary && { chatSummary: deps.chatSummary }),
881
- ...(deps.questionGenerator && { questionGenerator: deps.questionGenerator }),
882
- ...(deps.questionerEnqueue && { questionerEnqueue: deps.questionerEnqueue }),
883
- ...(context.scopeType && context.scopeId ? { scopeType: context.scopeType, scopeId: context.scopeId } : {}),
884
- ...(deps.negotiationSummary && { negotiationSummary: deps.negotiationSummary }),
885
- // Decision questions add an LLM call after the negotiation phase.
886
- // Capped at QUESTIONER_DISCOVERY_TIMEOUT_MS (12 s default,
887
- // env-overridable; see opportunity.discover.ts). Aborted calls return
888
- // no questions but the rest of the discovery payload still ships.
889
- // For chat sessions, questions are rendered by the frontend via
890
- // streamed events (Slice 4). For MCP, they drive a sequential
891
- // elicitation/create flow (Slice 5) — the MCP tool handler awaits the
892
- // elicitations before returning the tool result. The per-negotiation
893
- // summarizer is similarly capped at NEGOTIATION_SUMMARY_TIMEOUT_MS
894
- // (5 s default per negotiation). Gated by QUESTIONER_DISCOVERY_ENABLED
895
- // (hierarchical: requires QUESTIONER_ENABLED too).
896
- enableQuestions: isDiscoveryQuestionsEnabled() &&
897
- (!!context.sessionId || !!context.isMcp),
898
- });
899
- // ── Pending question injection ────────────────────────────────────
900
- // Look up previously-generated questions relevant to this user's
901
- // discovery context and merge them into the result alongside any
902
- // inline-generated questions from the current run.
903
- const pendingQuestionResult = await mergePendingQuestions({
904
- findPendingQuestions: deps.findPendingQuestions,
905
- userId: context.userId,
906
- sourceType: 'discovery',
907
- ...(context.scopeType === 'network' && context.scopeId ? { networkId: context.scopeId } : {}),
908
- ...(contextIntentId ? { scopeType: 'intent', scopeId: contextIntentId } : {}),
909
- surfacedQuestionIds: new Set(), // Dedup handled at chat.agent level
910
- });
911
- const pendingQuestions = pendingQuestionResult.questions;
912
- const _discoverGraphMs = Date.now() - _discoverGraphStart;
913
- _discoverTraceEmitter?.({ type: "graph_end", name: "opportunity", durationMs: _discoverGraphMs });
914
- const _discoverGraphTimings = [
915
- ..._scopeGraphTimings,
916
- { name: 'opportunity', durationMs: _discoverGraphMs, agents: [] },
917
- ];
918
- const allDebugSteps = [
919
- ...toolDebugSteps,
920
- ...(result.debugSteps ?? []),
921
- ];
922
- // Extract negotiation timing from trace (if negotiation ran)
923
- const negotiateStep = (result.debugSteps ?? []).find(s => s.step === 'negotiate' && s.data?.durationMs != null);
924
- const _allGraphTimings = [
925
- ..._discoverGraphTimings,
926
- ...(negotiateStep?.data?.durationMs != null
927
- ? [{ name: 'negotiation', durationMs: negotiateStep.data.durationMs, agents: [] }]
928
- : []),
929
- ];
930
- const isIntroducerFlow = !!query.introTargetUserId?.trim();
931
- if (result.createIntentSuggested && result.suggestedIntentDescription && !isIntroducerFlow) {
932
- return success({
933
- found: false,
934
- count: 0,
935
- createIntentSuggested: true,
936
- suggestedIntentDescription: result.suggestedIntentDescription,
937
- message: "No matching opportunities found. Call create_intent with the suggested description, then discover_opportunities again.",
938
- summary: "No matches found",
939
- ...(result.pagination ? { pagination: result.pagination } : {}),
940
- debugSteps: allDebugSteps,
941
- _graphTimings: _allGraphTimings,
942
- ...(() => {
943
- const allQ = [...(result.questions ?? []), ...pendingQuestions];
944
- return allQ.length > 0 ? { questions: allQ } : {};
945
- })(),
946
- ...(result.discoveryQuestionsDebug ? { _discoveryQuestionsDebug: result.discoveryQuestionsDebug } : {}),
947
- });
948
- }
949
- if (!result.found) {
950
- return success({
951
- found: false,
952
- count: 0,
953
- message: result.message ?? "No matching opportunities found.",
954
- summary: "No matches found",
955
- ...(result.pagination ? { pagination: result.pagination } : {}),
956
- debugSteps: allDebugSteps,
957
- _graphTimings: _allGraphTimings,
958
- ...(() => {
959
- const allQ = [...(result.questions ?? []), ...pendingQuestions];
960
- return allQ.length > 0 ? { questions: allQ } : {};
961
- })(),
962
- ...(result.discoveryQuestionsDebug ? { _discoveryQuestionsDebug: result.discoveryQuestionsDebug } : {}),
963
- });
964
- }
965
- // Found but only existing connections (no new opportunities created)
966
- const forMention = result.existingConnectionsForMention ?? result.existingConnections ?? [];
967
- if ((result.opportunities?.length ?? 0) === 0 && forMention.length > 0) {
968
- return success({
969
- found: true,
970
- count: 0,
971
- message: result.message ??
972
- "No new opportunities created; you already have a connection with: " +
973
- forMention.map((c) => c.name + (c.status ? " (" + c.status + ")" : "")).join(", ") +
974
- ". View on your home page.",
975
- existingConnections: result.existingConnections,
976
- summary: "No new matches (existing connections only)",
977
- debugSteps: allDebugSteps,
978
- _graphTimings: _allGraphTimings,
979
- ...(() => {
980
- const allQ = [...(result.questions ?? []), ...pendingQuestions];
981
- return allQ.length > 0 ? { questions: allQ } : {};
982
- })(),
983
- ...(result.discoveryQuestionsDebug ? { _discoveryQuestionsDebug: result.discoveryQuestionsDebug } : {}),
984
- });
985
- }
986
- const lifecycleFinalization = await finalizeMcpDiscoveryLifecycle({
987
- isMcp: context.isMcp === true,
988
- candidates: result.opportunities ?? [],
989
- existingConnections: result.existingConnections,
990
- existingConnectionsForMention: result.existingConnectionsForMention,
991
- alreadyAcceptedPairs: result.alreadyAcceptedPairs,
992
- pagination: result.pagination,
993
- isIntroducerFlow,
994
- displayLimit: CHAT_DISPLAY_LIMIT,
995
- readOpportunitiesByIds: (ids) => database.getOpportunitiesByIds(ids),
996
- warn: (message, data) => discoverOpportunitiesLog.warn(message, data),
997
- projectSafeCard: (opp) => ({
998
- opportunityId: opp.opportunityId,
999
- userId: opp.userId,
1000
- name: opp.name,
1001
- avatar: opp.avatar,
1002
- mainText: getSafePresentationOrSkip(opp, { counterpartName: opp.name })?.summary ?? "",
1003
- cta: opp.homeCardPresentation?.suggestedAction,
1004
- headline: opp.homeCardPresentation?.headline,
1005
- primaryActionLabel: opp.homeCardPresentation?.primaryActionLabel,
1006
- secondaryActionLabel: opp.homeCardPresentation?.secondaryActionLabel,
1007
- mutualIntentsLabel: opp.homeCardPresentation?.mutualIntentsLabel,
1008
- narratorChip: opp.narratorChip,
1009
- viewerRole: opp.viewerRole,
1010
- isGhost: opp.isGhost ?? false,
1011
- score: opp.score,
1012
- status: opp.status,
1013
- ...(opp.secondParty && { secondParty: opp.secondParty }),
1014
- }),
1015
- });
1016
- const { displayedCards, displayedCandidates } = lifecycleFinalization;
1017
- if (context.isMcp && deps.mintConnectLink) {
1018
- const mintConnectLink = deps.mintConnectLink;
1019
- await Promise.all(displayedCards.map(async (card, idx) => {
1020
- const source = displayedCandidates[idx];
1021
- await attachActionableLinks(card, {
1022
- viewerId: context.userId,
1023
- viewerApproved: source?.viewerApproved,
1024
- viewerActedAt: source?.viewerActedAt,
1025
- counterpartUserId: source?.userId ?? card.userId,
1026
- mintConnectLink,
1027
- frontendUrl: deps.frontendUrl,
1028
- preferredSurface: context.clientSurface,
1029
- });
1030
- }));
1031
- }
1032
- let message = buildOpportunityPresentation(displayedCards, {
1033
- isMcp: context.isMcp ?? false,
1034
- leadIn: `Found ${displayedCards.length} potential connection(s).`,
1035
- });
1036
- message = lifecycleFinalization.composeMessage(message);
1037
- return success({
1038
- found: true,
1039
- count: displayedCards.length,
1040
- message,
1041
- summary: `Found ${displayedCards.length} match(es)`,
1042
- ...(result.existingConnections?.length ? { existingConnections: result.existingConnections } : {}),
1043
- ...(result.pagination ? { pagination: result.pagination } : {}),
1044
- debugSteps: allDebugSteps,
1045
- // Distinct from `createIntentSuggested` (no-results path) intentionally:
1046
- // `handleCreateIntentCallback` in chat.agent.ts auto-creates for that key.
1047
- // This flag is for the results-found path where the agent must ask the user first.
1048
- ...(searchQuery && !query.targetUserId && !isIntroducerFlow
1049
- ? {
1050
- suggestIntentCreationForVisibility: true,
1051
- suggestedIntentDescription: searchQuery,
1052
- }
1053
- : {}),
1054
- ...(() => {
1055
- const allQ = [...(result.questions ?? []), ...pendingQuestions];
1056
- return allQ.length > 0 ? { questions: allQ } : {};
1057
- })(),
1058
- ...(result.discoveryQuestionsDebug ? { _discoveryQuestionsDebug: result.discoveryQuestionsDebug } : {}),
1059
- _graphTimings: _allGraphTimings,
1060
- });
1061
- },
1062
- });
1063
- const getDiscoveryRun = defineTool({
1064
- name: "get_discovery_run",
1065
- description: "Checks the status of an async discovery run started by discover_opportunities in MCP contexts. " +
1066
- "Poll this tool with the discoveryRunId roughly every 5 seconds until status is succeeded, failed, or cancelled. " +
1067
- "While status is queued or running, keep polling THIS tool — do NOT call discover_opportunities again (that does not speed anything up and returns the same run). " +
1068
- "When succeeded, the result field contains the same discovery payload that discover_opportunities would have returned synchronously.",
1069
- querySchema: z.object({
1070
- discoveryRunId: z.string().describe("Discovery run ID returned by discover_opportunities."),
1071
- }),
1072
- handler: async ({ context, query }) => {
1073
- if (!deps.discoveryRuns) {
1074
- return error("Async discovery runs are not available in this context.");
1075
- }
1076
- const run = await deps.discoveryRuns.get(query.discoveryRunId, context.userId);
1077
- if (!run)
1078
- return error("Discovery run not found.");
1079
- return success({
1080
- discoveryRunId: run.id,
1081
- status: run.status,
1082
- progress: run.progress ?? null,
1083
- result: run.result ?? null,
1084
- error: run.error ?? null,
1085
- cancelRequestedAt: run.cancelRequestedAt?.toISOString?.() ?? null,
1086
- createdAt: run.createdAt.toISOString(),
1087
- startedAt: run.startedAt?.toISOString?.() ?? null,
1088
- completedAt: run.completedAt?.toISOString?.() ?? null,
1089
- });
1090
- },
1091
- });
1092
- const cancelDiscoveryRun = defineTool({
1093
- name: "cancel_discovery_run",
1094
- description: "Requests cancellation for an async discovery run. If the queued job has not started, it is removed and marked cancelled. " +
1095
- "If already running, the worker observes cancellation and stops at the next cancellation check.",
1096
- querySchema: z.object({
1097
- discoveryRunId: z.string().describe("Discovery run ID returned by discover_opportunities."),
1098
- }),
1099
- handler: async ({ context, query }) => {
1100
- if (!deps.discoveryRuns || !deps.discoveryRunQueue) {
1101
- return error("Async discovery runs are not available in this context.");
1102
- }
1103
- const existing = await deps.discoveryRuns.get(query.discoveryRunId, context.userId);
1104
- if (!existing)
1105
- return error("Discovery run not found.");
1106
- if (!["queued", "running"].includes(existing.status)) {
1107
- return success({
1108
- discoveryRunId: existing.id,
1109
- status: existing.status,
1110
- cancelled: existing.status === "cancelled",
1111
- message: `Discovery run is already ${existing.status}.`,
1112
- });
1113
- }
1114
- const run = await deps.discoveryRuns.requestCancel(query.discoveryRunId, context.userId);
1115
- if (!run)
1116
- return error("Discovery run is no longer cancellable.");
1117
- const removed = await deps.discoveryRunQueue.cancel(run.id);
1118
- if (removed) {
1119
- await deps.discoveryRuns.markCancelled(run.id, "cancelled before worker start");
1120
- }
1121
- const updated = await deps.discoveryRuns.get(run.id, context.userId);
1122
- const status = updated?.status ?? (removed ? "cancelled" : run.status);
1123
- const message = removed
1124
- ? "Discovery run cancelled."
1125
- : status === "queued"
1126
- ? "Cancellation requested while the discovery run is still queued. It will be skipped or cancelled before work starts."
1127
- : status === "running"
1128
- ? "Cancellation requested. The running worker will stop at the next cancellation check."
1129
- : `Cancellation requested. Discovery run is now ${status}.`;
1130
- return success({
1131
- discoveryRunId: run.id,
1132
- status,
1133
- cancelled: removed || status === "cancelled",
1134
- message,
1135
- });
1136
- },
1137
- });
1138
304
  const listOpportunities = defineTool({
1139
305
  name: "list_opportunities",
1140
306
  description: "Lists the authenticated user's actionable opportunities (discovered connections). Returns opportunity cards ready for display.\n\n" +
@@ -1248,7 +414,7 @@ export function createOpportunityTools(defineTool, deps) {
1248
414
  found: false,
1249
415
  count: 0,
1250
416
  summary: "No opportunities yet",
1251
- message: "You have no opportunities yet. Use discover_opportunities to find connections.",
417
+ message: "You have no opportunities yet. Create or refine an approved signal with create_intent or update_intent; matches are created in the background. Use list_opportunities later to review persisted results.",
1252
418
  });
1253
419
  }
1254
420
  // Batch-fetch profiles and users for all counterpart and introducer userIds to avoid N+1
@@ -1594,7 +760,7 @@ export function createOpportunityTools(defineTool, deps) {
1594
760
  found: false,
1595
761
  count: 0,
1596
762
  summary: "No opportunities yet",
1597
- message: "You have no opportunities yet. Use discover_opportunities to find connections.",
763
+ message: "You have no opportunities yet. Create or refine an approved signal with create_intent or update_intent; matches are created in the background. Use list_opportunities later to review persisted results.",
1598
764
  });
1599
765
  }
1600
766
  return success({
@@ -1615,22 +781,33 @@ export function createOpportunityTools(defineTool, deps) {
1615
781
  description: "Updates an opportunity's status, advancing it through the connection lifecycle.\n\n" +
1616
782
  "**Status transitions:**\n" +
1617
783
  "- `pending`: Sends a draft opportunity to the other party. They'll be notified and can accept or reject. " +
1618
- "This is the primary action after discover_opportunities returns a draft.\n" +
784
+ "This is the primary action after a persisted draft is returned.\n" +
1619
785
  "- `accepted`: Accept a received opportunity — opens a direct conversation between both parties. Returns a conversationId to surface to the user.\n" +
1620
786
  "- `rejected`: Decline a received opportunity.\n" +
1621
787
  "- `expired`: Mark as expired (typically done by the system after timeout).\n\n" +
1622
- "**When to use:** After discover_opportunities or list_opportunities returns opportunity cards. " +
788
+ "**When to use:** After list_opportunities returns persisted opportunity cards. " +
1623
789
  "The user clicks 'Send' (pending), 'Accept', or 'Reject' on the card, and the agent calls this tool. " +
1624
790
  "An accepted transition may first return a non-success uptake advisory with preparatory questions. Surface those questions, then retry with all returned question ids in acknowledgedUptakeQuestionIds; acknowledgement confirms presentation, not an answer.\n\n" +
791
+ "**Owner approval (agents):** Agent-driven send/accept/reject transitions require an explicit owner-issued approval proof. " +
792
+ "Call without ownerApprovalProof first: the denial returns an approval challenge (interactionId, expiresAt) bound to the exact opportunity, action, owner, and agent. " +
793
+ "Relay that challenge to the owner for explicit approval, then retry once with the issued ownerApprovalProof. " +
794
+ "Proofs are single-use and expire; acknowledgedUptakeQuestionIds, negotiation approvals, and advisory values are never substitutes.\n\n" +
1625
795
  "**Returns:** Confirmation with the new status and notification details (who was notified), or a structured uptake advisory without mutation.",
1626
796
  querySchema: z.object({
1627
797
  opportunityId: z
1628
798
  .string()
1629
- .describe("The UUID of the opportunity to update. Get from discover_opportunities or list_opportunities results."),
799
+ .describe("The UUID of the opportunity to update. Get from list_opportunities results."),
1630
800
  status: z
1631
801
  .enum(["pending", "accepted", "rejected", "expired"])
1632
802
  .describe("New status: 'pending' = send the draft to the other party, 'accepted' = accept the connection, " +
1633
803
  "'rejected' = decline, 'expired' = mark as timed out."),
804
+ ownerApprovalProof: z
805
+ .string()
806
+ .min(1)
807
+ .optional()
808
+ .describe("Opaque owner-issued approval proof for this exact transition (agents only). Obtained after the owner " +
809
+ "explicitly approves the interaction challenge returned by a proof-less call. The opportunity, action, " +
810
+ "owner, agent, and interaction binding is always derived server-side; only this token is presented."),
1634
811
  acknowledgedUptakeQuestionIds: z
1635
812
  .array(z.string().min(1))
1636
813
  .optional()
@@ -1677,6 +854,39 @@ export function createOpportunityTools(defineTool, deps) {
1677
854
  });
1678
855
  if (admission.kind === 'denied')
1679
856
  return error(admission.message);
857
+ // IND-593 owner-approval boundary: every owner-gated transition
858
+ // (send/accept/reject) requires an explicit owner-issued, fresh,
859
+ // atomically single-use proof before any graph/state persistence. The
860
+ // binding is derived ONLY from the resolved context and validated input —
861
+ // caller-controlled identity or proof-binding fields are never trusted.
862
+ // Registered agents present the opaque proof token; direct authenticated
863
+ // owners traverse the same boundary via host attestation. Fail closed
864
+ // when no authority is wired.
865
+ const ownerAction = opportunityOwnerActionForStatus(query.status);
866
+ if (ownerAction) {
867
+ const authority = deps.opportunityOwnerApproval;
868
+ if (!authority) {
869
+ return ownerApprovalDenial(opportunityId, ownerAction, { kind: 'denied', reason: 'missing' });
870
+ }
871
+ const directProvenance = ownerApprovalProvenanceFor(context);
872
+ const verdict = context.agentId
873
+ ? await authority.consumeAgentProof(query.ownerApprovalProof, {
874
+ opportunityId,
875
+ action: ownerAction,
876
+ ownerId: context.userId,
877
+ agentId: context.agentId,
878
+ })
879
+ : directProvenance
880
+ ? await authority.attestOwnerInteraction({
881
+ opportunityId,
882
+ action: ownerAction,
883
+ ownerId: context.userId,
884
+ provenance: directProvenance,
885
+ })
886
+ : { kind: 'denied', reason: 'untrusted_provenance' };
887
+ if (verdict.kind === 'denied')
888
+ return ownerApprovalDenial(opportunityId, ownerAction, verdict);
889
+ }
1680
890
  // The caller actor's own network is the exact question lookup boundary,
1681
891
  // even for an otherwise unscoped request. A focused network may only be
1682
892
  // equal to this after the guard above.
@@ -1824,9 +1034,6 @@ export function createOpportunityTools(defineTool, deps) {
1824
1034
  },
1825
1035
  });
1826
1036
  return [
1827
- discoverOpportunities,
1828
- getDiscoveryRun,
1829
- cancelDiscoveryRun,
1830
1037
  listOpportunities,
1831
1038
  updateOpportunity,
1832
1039
  confirmOpportunityDelivery,