@indexnetwork/protocol 14.3.2-rc.477.1 → 17.0.0-rc.479.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/IMPLEMENTATION.md +50 -52
  3. package/STABILITY.md +3 -3
  4. package/dist/chat/chat.agent.js +2 -3
  5. package/dist/chat/chat.graph.d.ts +41 -1
  6. package/dist/chat/chat.graph.js +108 -127
  7. package/dist/chat/negotiator.persona.d.ts +1 -1
  8. package/dist/chat/negotiator.persona.js +1 -4
  9. package/dist/chat/negotiator.prompt.js +1 -2
  10. package/dist/chat/onboarding.persona.js +0 -1
  11. package/dist/chat/reporter.persona.js +0 -1
  12. package/dist/chat/signal.persona.js +0 -1
  13. package/dist/contacts/application/contact.tools.d.ts +6 -9
  14. package/dist/contacts/application/contact.tools.js +11 -92
  15. package/dist/contacts/application/index.d.ts +2 -14
  16. package/dist/contacts/application/index.js +2 -13
  17. package/dist/contacts/domain/contact.types.d.ts +0 -23
  18. package/dist/contacts/domain/index.d.ts +2 -6
  19. package/dist/contacts/index.d.ts +1 -1
  20. package/dist/contacts/index.js +1 -1
  21. package/dist/contacts/ports/contact.repository.port.d.ts +5 -12
  22. package/dist/contacts/ports/contact.repository.port.js +4 -6
  23. package/dist/contacts/ports/contact.tools.port.d.ts +1 -3
  24. package/dist/contacts/ports/contact.tools.port.js +1 -2
  25. package/dist/contacts/ports/index.d.ts +1 -1
  26. package/dist/contacts/ports/index.js +1 -1
  27. package/dist/discovery/hyde.frame.d.ts +4 -4
  28. package/dist/discovery/hyde.graph.d.ts +71 -12
  29. package/dist/discovery/hyde.graph.js +337 -335
  30. package/dist/discovery/lens.inferrer.d.ts +6 -6
  31. package/dist/enrichment/enrichment.graph.d.ts +127 -9
  32. package/dist/enrichment/enrichment.graph.js +633 -655
  33. package/dist/enrichment/enrichment.state.d.ts +1 -1
  34. package/dist/enrichment/enrichment.tools.context-read.d.ts +10 -0
  35. package/dist/enrichment/enrichment.tools.context-read.js +353 -0
  36. package/dist/enrichment/enrichment.tools.context-write.d.ts +9 -0
  37. package/dist/enrichment/enrichment.tools.context-write.js +417 -0
  38. package/dist/enrichment/enrichment.tools.d.ts +11 -1
  39. package/dist/enrichment/enrichment.tools.helpers.d.ts +139 -0
  40. package/dist/enrichment/enrichment.tools.helpers.js +234 -0
  41. package/dist/enrichment/enrichment.tools.js +16 -975
  42. package/dist/index.d.ts +27 -50
  43. package/dist/index.js +17 -28
  44. package/dist/intents/application/intent.graph.d.ts +71 -67
  45. package/dist/intents/application/intent.graph.execute.d.ts +59 -0
  46. package/dist/intents/application/intent.graph.execute.js +301 -0
  47. package/dist/intents/application/intent.graph.infer.d.ts +44 -0
  48. package/dist/intents/application/intent.graph.infer.js +97 -0
  49. package/dist/intents/application/intent.graph.js +96 -888
  50. package/dist/intents/application/intent.graph.reconcile.d.ts +71 -0
  51. package/dist/intents/application/intent.graph.reconcile.js +274 -0
  52. package/dist/intents/application/intent.graph.shared.d.ts +70 -0
  53. package/dist/intents/application/intent.graph.shared.js +153 -0
  54. package/dist/intents/domain/intent.state.d.ts +1 -1
  55. package/dist/maintenance/maintenance.graph.d.ts +66 -3
  56. package/dist/maintenance/maintenance.graph.js +155 -156
  57. package/dist/mcp/mcp.authorization-policy.d.ts +24 -28
  58. package/dist/mcp/mcp.authorization-policy.js +14 -34
  59. package/dist/mcp/mcp.server.d.ts +3 -3
  60. package/dist/mcp/mcp.server.js +14 -20
  61. package/dist/negotiations/application/negotiation.candidates.d.ts +83 -0
  62. package/dist/negotiations/application/negotiation.candidates.js +162 -0
  63. package/dist/negotiations/application/negotiation.graph.d.ts +91 -157
  64. package/dist/negotiations/application/negotiation.graph.finalize.d.ts +5 -0
  65. package/dist/negotiations/application/negotiation.graph.finalize.js +280 -0
  66. package/dist/negotiations/application/negotiation.graph.init.d.ts +54 -0
  67. package/dist/negotiations/application/negotiation.graph.init.js +227 -0
  68. package/dist/negotiations/application/negotiation.graph.js +69 -1388
  69. package/dist/negotiations/application/negotiation.graph.screen.d.ts +23 -0
  70. package/dist/negotiations/application/negotiation.graph.screen.js +108 -0
  71. package/dist/negotiations/application/negotiation.graph.shared.d.ts +75 -0
  72. package/dist/negotiations/application/negotiation.graph.shared.js +125 -0
  73. package/dist/negotiations/application/negotiation.graph.turn.d.ts +105 -0
  74. package/dist/negotiations/application/negotiation.graph.turn.js +484 -0
  75. package/dist/negotiations/domain/negotiation.state.d.ts +1 -1
  76. package/dist/negotiations/domain/negotiation.state.js +0 -1
  77. package/dist/networks/application/indexer.graph.d.ts +165 -7
  78. package/dist/networks/application/indexer.graph.js +339 -388
  79. package/dist/networks/application/indexer.state.d.ts +1 -1
  80. package/dist/networks/application/membership.graph.d.ts +83 -5
  81. package/dist/networks/application/membership.graph.js +177 -207
  82. package/dist/networks/application/network.graph.d.ts +148 -5
  83. package/dist/networks/application/network.graph.js +249 -278
  84. package/dist/networks/domain/membership.state.d.ts +1 -1
  85. package/dist/networks/domain/network.state.d.ts +1 -1
  86. package/dist/opportunities/application/delivery-card.cache.d.ts +1 -1
  87. package/dist/opportunities/application/delivery-card.cache.js +2 -2
  88. package/dist/opportunities/application/index.d.ts +2 -2
  89. package/dist/opportunities/application/index.js +2 -2
  90. package/dist/opportunities/application/opportunity.evaluator.js +1 -1
  91. package/dist/opportunities/application/opportunity.graph.d.ts +686 -473
  92. package/dist/opportunities/application/opportunity.graph.discovery-strategies.d.ts +109 -0
  93. package/dist/opportunities/application/opportunity.graph.discovery-strategies.js +451 -0
  94. package/dist/opportunities/application/opportunity.graph.discovery.d.ts +69 -0
  95. package/dist/opportunities/application/opportunity.graph.discovery.js +397 -0
  96. package/dist/opportunities/application/opportunity.graph.evaluation.d.ts +82 -0
  97. package/dist/opportunities/application/opportunity.graph.evaluation.js +608 -0
  98. package/dist/opportunities/application/opportunity.graph.js +120 -3690
  99. package/dist/opportunities/application/opportunity.graph.modes.d.ts +538 -0
  100. package/dist/opportunities/application/opportunity.graph.modes.js +538 -0
  101. package/dist/opportunities/application/opportunity.graph.negotiate.d.ts +109 -0
  102. package/dist/opportunities/application/opportunity.graph.negotiate.js +392 -0
  103. package/dist/opportunities/application/opportunity.graph.persist-node.d.ts +93 -0
  104. package/dist/opportunities/application/opportunity.graph.persist-node.js +765 -0
  105. package/dist/opportunities/application/opportunity.graph.prep.d.ts +150 -0
  106. package/dist/opportunities/application/opportunity.graph.prep.js +381 -0
  107. package/dist/opportunities/application/opportunity.graph.shared.d.ts +173 -0
  108. package/dist/opportunities/application/opportunity.graph.shared.js +199 -0
  109. package/dist/opportunities/application/opportunity.presentation.d.ts +399 -0
  110. package/dist/opportunities/application/{opportunity.presenter.js → opportunity.presentation.js} +735 -9
  111. package/dist/opportunities/application/opportunity.tools.cards.d.ts +152 -0
  112. package/dist/opportunities/application/opportunity.tools.cards.js +235 -0
  113. package/dist/opportunities/application/opportunity.tools.d.ts +8 -114
  114. package/dist/opportunities/application/opportunity.tools.js +28 -711
  115. package/dist/opportunities/application/opportunity.tools.list.d.ts +10 -0
  116. package/dist/opportunities/application/opportunity.tools.list.js +493 -0
  117. package/dist/opportunities/domain/index.d.ts +3 -3
  118. package/dist/opportunities/domain/index.js +3 -3
  119. package/dist/opportunities/domain/opportunity.state.d.ts +19 -19
  120. package/dist/opportunities/index.d.ts +6 -6
  121. package/dist/opportunities/index.js +4 -4
  122. package/dist/opportunities/ports/opportunity.tools.port.d.ts +1 -1
  123. package/dist/opportunities/radar/radar.graph.d.ts +57 -13
  124. package/dist/opportunities/radar/radar.graph.js +470 -471
  125. package/dist/premises/premise.graph.d.ts +88 -20
  126. package/dist/premises/premise.graph.js +207 -218
  127. package/dist/questions/domain/question.schema.d.ts +40 -40
  128. package/dist/shared/agent/tool.factory.js +0 -8
  129. package/dist/shared/agent/tool.helpers.d.ts +21 -45
  130. package/dist/shared/agent/tool.helpers.js +1 -2
  131. package/dist/shared/agent/tool.registry.d.ts +4 -3
  132. package/dist/shared/agent/tool.registry.js +2 -4
  133. package/dist/shared/agent/tool.runtime.js +0 -2
  134. package/dist/shared/agent/utility.tools.js +9 -13
  135. package/dist/shared/interfaces/database.capabilities.d.ts +100 -0
  136. package/dist/shared/interfaces/database.capabilities.js +7 -0
  137. package/dist/shared/interfaces/database.entities.d.ts +533 -0
  138. package/dist/shared/interfaces/database.entities.js +10 -0
  139. package/dist/shared/interfaces/database.identity-queries.d.ts +294 -0
  140. package/dist/shared/interfaces/database.identity-queries.js +4 -0
  141. package/dist/shared/interfaces/database.interface.d.ts +15 -2277
  142. package/dist/shared/interfaces/database.interface.js +8 -0
  143. package/dist/shared/interfaces/database.member-queries.d.ts +221 -0
  144. package/dist/shared/interfaces/database.member-queries.js +4 -0
  145. package/dist/shared/interfaces/database.negotiation.d.ts +305 -0
  146. package/dist/shared/interfaces/database.negotiation.js +26 -0
  147. package/dist/shared/interfaces/database.network-queries.d.ts +277 -0
  148. package/dist/shared/interfaces/database.network-queries.js +4 -0
  149. package/dist/shared/interfaces/database.opportunity-queries.d.ts +285 -0
  150. package/dist/shared/interfaces/database.opportunity-queries.js +4 -0
  151. package/dist/shared/interfaces/database.port.d.ts +322 -0
  152. package/dist/shared/interfaces/database.port.js +29 -0
  153. package/dist/shared/schemas/discovery-question.schema.d.ts +14 -14
  154. package/dist/shared/schemas/negotiation-digest.schema.d.ts +2 -2
  155. package/package.json +2 -15
  156. package/dist/contacts/application/contact.inviter.d.ts +0 -49
  157. package/dist/contacts/application/contact.inviter.js +0 -64
  158. package/dist/integrations/application/index.d.ts +0 -14
  159. package/dist/integrations/application/index.js +0 -14
  160. package/dist/integrations/application/integration.tools.d.ts +0 -27
  161. package/dist/integrations/application/integration.tools.js +0 -98
  162. package/dist/integrations/domain/index.d.ts +0 -16
  163. package/dist/integrations/domain/index.js +0 -1
  164. package/dist/integrations/domain/integration.types.d.ts +0 -51
  165. package/dist/integrations/domain/integration.types.js +0 -9
  166. package/dist/integrations/index.d.ts +0 -10
  167. package/dist/integrations/index.js +0 -8
  168. package/dist/integrations/ports/index.d.ts +0 -25
  169. package/dist/integrations/ports/index.js +0 -23
  170. package/dist/integrations/ports/integration.adapter.port.d.ts +0 -62
  171. package/dist/integrations/ports/integration.adapter.port.js +0 -15
  172. package/dist/integrations/ports/integration.importer.port.d.ts +0 -31
  173. package/dist/integrations/ports/integration.importer.port.js +0 -15
  174. package/dist/integrations/ports/integration.tools.port.d.ts +0 -25
  175. package/dist/integrations/ports/integration.tools.port.js +0 -18
  176. package/dist/opportunities/application/opportunity.card-presentation.d.ts +0 -44
  177. package/dist/opportunities/application/opportunity.card-presentation.js +0 -93
  178. package/dist/opportunities/application/opportunity.presenter.d.ts +0 -156
  179. package/dist/opportunities/domain/opportunity.presentation-cache.d.ts +0 -5
  180. package/dist/opportunities/domain/opportunity.presentation-cache.js +0 -11
  181. package/dist/opportunities/domain/opportunity.presentation.d.ts +0 -76
  182. package/dist/opportunities/domain/opportunity.presentation.js +0 -516
  183. package/dist/opportunities/domain/opportunity.safe-presentation.d.ts +0 -104
  184. package/dist/opportunities/domain/opportunity.safe-presentation.js +0 -102
@@ -1,720 +1,31 @@
1
+ /**
2
+ * The opportunity tool registry: list, update, and delivery confirmation.
3
+ *
4
+ * `list_opportunities` lives in `opportunity.tools.list.ts` and the shared card
5
+ * and link helpers in `opportunity.tools.cards.ts`; this file wires the three
6
+ * tools together and owns the two mutation tools.
7
+ */
1
8
  import { z } from "zod";
2
9
  import { requestContext } from "../../shared/observability/request-context.js";
3
10
  import { success, error, UUID_REGEX } from "../../shared/agent/tool.helpers.js";
4
- import { focusedIntentId, focusedNetworkId, focusedNetworkLabel } from "../../shared/agent/tool.scope.js";
5
- import { MINIMAL_MAIN_TEXT_MAX_CHARS, getPrimaryActionLabel, SECONDARY_ACTION_LABEL } from "../domain/opportunity.labels.js";
6
- import { narratorRemarkFromReasoning, stripUuids } from "../domain/opportunity.presentation.js";
7
- import { safeFallbackSummary } from "../domain/opportunity.safe-presentation.js";
8
- import { buildOpportunityPresentation } from "./opportunity.card-presentation.js";
11
+ import { focusedIntentId, focusedNetworkId } from "../../shared/agent/tool.scope.js";
12
+ import { OpportunityPresenter, gatherPresenterContext } from "./opportunity.presentation.js";
9
13
  import { isUptakeGuardEnabled } from "../../questions/index.js";
10
- import { OpportunityPresenter, gatherPresenterContext } from "./opportunity.presenter.js";
11
- import { loadNegotiationContext } from "./negotiation-context.loader.js";
12
14
  import { admitOpportunityUpdate } from './opportunity.update-admission.js';
13
15
  import { opportunityOwnerActionForStatus } from './opportunity.owner-approval.js';
14
16
  import { ownerApprovalProvenanceFor } from './opportunity.owner-provenance.js';
15
- import { selectOpportunityFeed } from './opportunity.feed-selection.js';
16
- export { buildOpportunityPresentation } from "./opportunity.card-presentation.js";
17
- function stripLeadingNarratorName(remark, narratorName) {
18
- let t = remark.trim();
19
- if (!t || !narratorName.trim())
20
- return remark;
21
- const name = narratorName.trim();
22
- const nameLower = name.toLowerCase();
23
- for (;;) {
24
- const lower = t.toLowerCase();
25
- if (!lower.startsWith(nameLower))
26
- break;
27
- // Require a word boundary after the name so a short name like "Al" does not
28
- // mangle a longer word ("Always …" → "ways …"). The char after the name must
29
- // be a separator (sentence/clause punctuation or whitespace) or end of string.
30
- // Keep this set in sync with the separator stripped from `rest` below.
31
- const boundary = t.charAt(name.length);
32
- if (boundary && !/[\s.:,\-–—]/.test(boundary))
33
- break;
34
- const rest = t.slice(name.length).replace(/^\s*[.:,\-–—]\s*/i, '').trim();
35
- if (rest.length === 0 || rest === t)
36
- break;
37
- t = rest;
38
- }
39
- return t;
40
- }
41
- import { protocolLogger } from "../../shared/observability/protocol.logger.js";
42
- import { invokeWithAbortSignal } from "../../shared/agent/model-signal.js";
43
- const logger = protocolLogger("ChatTools:Opportunity");
44
- /**
45
- * Build the agent-facing profile link for a counterpart — always the Index web
46
- * profile URL with `?link_preview=false`. Returns `undefined` only if
47
- * `frontendUrl` is not configured.
48
- *
49
- * The `?link_preview=false` hint is honored by chat-gateway runtimes (e.g.
50
- * OpenClaw's Telegram delivery) that strip link previews when present in the
51
- * URL; consistent placement matters more than Telegram's own handling.
52
- *
53
- * Trailing slashes on frontendUrl are stripped before concatenation.
54
- */
55
- export function buildProfileUrl(counterpartUserId, frontendUrl) {
56
- // The profile link is always the Index web profile, regardless of the
57
- // counterpart's socials.
58
- if (!frontendUrl)
59
- return undefined;
60
- const base = frontendUrl.replace(/\/+$/, "");
61
- return `${base}/u/${counterpartUserId}?link_preview=false`;
62
- }
63
- /**
64
- * Build the deep-link to an opportunity's A2A negotiation trace
65
- * (`/chat/:conversationId`) so users can see *what negotiation led to* the
66
- * surfaced opportunity (EDG-50/EDG-51). Returns `undefined` when `frontendUrl`
67
- * is unset or there is no negotiation conversation to link to.
68
- *
69
- * The `?link_preview=false` hint mirrors `buildProfileUrl` — chat-gateway
70
- * runtimes (e.g. Telegram delivery) strip link previews when it is present.
71
- * Trailing slashes on `frontendUrl` are stripped before concatenation.
72
- */
73
- export function buildNegotiationUrl(conversationId, frontendUrl) {
74
- if (!frontendUrl || !conversationId)
75
- return undefined;
76
- const base = frontendUrl.replace(/\/+$/, "");
77
- return `${base}/chat/${conversationId}?link_preview=false`;
78
- }
79
- /**
80
- * Build the agent-facing deep link for an opportunity — the canonical
81
- * `https://index.network/o/<id>` universal link. Returns `undefined` when
82
- * `frontendUrl` is unset or there is no opportunity id.
83
- *
84
- * This is the *only* place the protocol mints an opportunity deep link. It is
85
- * a navigation link, not an authority: opening it raises the opportunity card
86
- * in the Index macOS app when installed and a static Index landing page
87
- * otherwise. Acceptance stays an authenticated call.
88
- *
89
- * No `?link_preview=false` hint here (unlike `buildProfileUrl`): the Hermes
90
- * plugin mints the identical bare form for payloads the protocol does not
91
- * touch, and keeping the two byte-identical is what makes its never-overwrite
92
- * rule invisible.
93
- */
94
- export function buildOpportunityAppUrl(opportunityId, frontendUrl) {
95
- if (!frontendUrl || !opportunityId)
96
- return undefined;
97
- const base = frontendUrl.replace(/\/+$/, "");
98
- return `${base}/o/${opportunityId}`;
99
- }
100
- /**
101
- * Attach the agent-facing profile link for a counterpart to `card` (mutates
102
- * in place). Every counterpart has a profile page worth linking to — without
103
- * this, the agent gets a name with no URL attached and tends to fabricate
104
- * one. Accept/act guidance is plain text ("accept in the Index app"); no
105
- * actionable URLs are minted here.
106
- */
107
- export function attachProfileLink(card, opts) {
108
- const profileUrl = buildProfileUrl(opts.counterpartUserId, opts.frontendUrl);
109
- if (profileUrl)
110
- card.profileUrl = profileUrl;
111
- }
112
- /**
113
- * Attach the opportunity deep link to `card` (mutates in place) so every MCP
114
- * client — Claude Desktop, the CLI, the web, Hermes — can hand the user one
115
- * clickable link to the card instead of fabricating one from an id.
116
- */
117
- export function attachOpportunityAppLink(card, opts) {
118
- const appUrl = buildOpportunityAppUrl(card.opportunityId, opts.frontendUrl);
119
- if (appUrl)
120
- card.appUrl = appUrl;
121
- }
122
- function publicUptakeQuestion(question) {
123
- return {
124
- id: question.id,
125
- title: question.title,
126
- prompt: question.prompt,
127
- options: question.options,
128
- multiSelect: question.multiSelect,
129
- };
130
- }
131
- function uptakeAdvisory(opportunityId, questions) {
132
- return JSON.stringify({
133
- success: false,
134
- error: "Resolve the pending uptake questions or explicitly continue anyway.",
135
- advisory: {
136
- code: "unresolved_uptake_questions",
137
- advisoryOnly: true,
138
- opportunityId,
139
- questions,
140
- acknowledgedUptakeQuestionIds: questions.map((question) => question.id),
141
- },
142
- });
143
- }
144
- /**
145
- * IND-593: stable fail-closed denial for the owner-approval boundary. The
146
- * `missing` reason carries the fresh, server-derived interaction challenge the
147
- * owner must explicitly approve; all other reasons carry no challenge.
148
- */
149
- function ownerApprovalDenial(opportunityId, action, verdict) {
150
- return JSON.stringify({
151
- success: false,
152
- error: `Owner approval required for this opportunity ${action} (${verdict.reason}).`,
153
- approval: {
154
- code: "owner_approval_required",
155
- reason: verdict.reason,
156
- opportunityId,
157
- action,
158
- ...(verdict.challenge
159
- ? { interactionId: verdict.challenge.interactionId, expiresAt: verdict.challenge.expiresAt }
160
- : {}),
161
- },
162
- });
163
- }
164
- /**
165
- * Maximum number of opportunity cards to show per chat response.
166
- * Sized for `selectByComposition` to fill both feed buckets — up to 3
167
- * connection + 3 connector-flow per the digest/ambient prompt rules.
168
- */
169
- const CHAT_DISPLAY_LIMIT = 6;
170
- /**
171
- * Build minimal opportunity card data for chat without calling the LLM presenter.
172
- * Uses only required fields from the opportunity record and counterpart name/avatar
173
- * so list_opportunities and discovery return quickly.
174
- *
175
- * Note: narratorChip.text is generated via regex heuristics (narratorRemarkFromReasoning)
176
- * rather than the OpportunityPresenter LLM. If narrator quality becomes an issue again,
177
- * consider making this function async and delegating to OpportunityPresenter.presentCard()
178
- * which already produces a high-quality narratorRemark via LLM (used by the home graph
179
- * and discovery pipeline). The trade-off is 5-20s latency per card.
180
- *
181
- * Exported for use in tests (opportunity.tools.spec.ts).
182
- */
183
- export function buildMinimalOpportunityCard(opp, viewerId, counterpartUserId, counterpartName, counterpartAvatar, introducerName, introducerAvatar, viewerName, secondPartyName, secondPartyAvatar, secondPartyUserId, isCounterpartGhost) {
184
- const viewerActor = opp.actors.find((a) => a.userId === viewerId);
185
- const viewerRole = viewerActor?.role ?? "party";
186
- const introducerActor = opp.actors.find((a) => a.role === "introducer" && a.userId !== viewerId);
187
- const viewerIsIntroducer = opp.actors.some((a) => a.role === "introducer" && a.userId === viewerId);
188
- const reasoning = opp.interpretation?.reasoning ?? "";
189
- // Shared sanitization standard — see opportunity.safe-presentation.ts.
190
- const mainText = safeFallbackSummary(reasoning, {
191
- counterpartName,
192
- viewerName,
193
- introducerName: introducerName ?? undefined,
194
- maxChars: MINIMAL_MAIN_TEXT_MAX_CHARS,
195
- emptyText: "A suggested connection.",
196
- });
197
- const score = typeof opp.interpretation?.confidence === "number"
198
- ? opp.interpretation.confidence
199
- : undefined;
200
- const narratorName = viewerIsIntroducer
201
- ? "You"
202
- : introducerName?.trim() || (introducerActor ? "Someone" : "Index");
203
- const primaryActionLabel = getPrimaryActionLabel(viewerRole);
204
- return {
205
- opportunityId: opp.id,
206
- userId: counterpartUserId,
207
- name: counterpartName,
208
- avatar: counterpartAvatar,
209
- mainText,
210
- cta: "Start a conversation to connect.",
211
- headline: viewerIsIntroducer && secondPartyName
212
- ? `${counterpartName} → ${secondPartyName}`
213
- : `Connection with ${counterpartName}`,
214
- primaryActionLabel,
215
- secondaryActionLabel: SECONDARY_ACTION_LABEL,
216
- mutualIntentsLabel: "Suggested connection",
217
- narratorChip: {
218
- name: narratorName,
219
- text: narratorRemarkFromReasoning(reasoning, counterpartName, viewerName),
220
- ...(viewerIsIntroducer
221
- ? { userId: viewerId, avatar: null }
222
- : introducerActor
223
- ? { userId: introducerActor.userId, avatar: introducerAvatar ?? null }
224
- : {}),
225
- },
226
- viewerRole,
227
- score,
228
- status: opp.status ?? "latent",
229
- isGhost: isCounterpartGhost ?? false,
230
- ...(viewerIsIntroducer && secondPartyName
231
- ? {
232
- secondParty: {
233
- name: secondPartyName,
234
- ...(secondPartyAvatar != null ? { avatar: secondPartyAvatar } : {}),
235
- ...(secondPartyUserId ? { userId: secondPartyUserId } : {}),
236
- },
237
- }
238
- : {}),
239
- };
240
- }
241
- function confirmDeliveryError(code, retryable, message) {
242
- return JSON.stringify({ success: false, error: message, code, retryable });
243
- }
17
+ export { buildOpportunityPresentation } from "./opportunity.presentation.js";
18
+ import { sendOpportunity, updateOpportunityStatus } from "./opportunity.graph.modes.js";
19
+ import { createListOpportunitiesTool } from "./opportunity.tools.list.js";
20
+ import { confirmDeliveryError, logger, ownerApprovalDenial, publicUptakeQuestion, uptakeAdvisory } from "./opportunity.tools.cards.js";
21
+ export { attachOpportunityAppLink, attachProfileLink, buildMinimalOpportunityCard, buildNegotiationUrl, buildOpportunityAppUrl, buildProfileUrl } from "./opportunity.tools.cards.js";
244
22
  export function createOpportunityTools(defineTool, deps) {
245
23
  const { database, userDb, systemDb, graphs, cache } = deps;
246
24
  const createOpportunityPresenter = deps.opportunityPresentation?.createPresenter ??
247
25
  (() => new OpportunityPresenter());
248
26
  const gatherOpportunityPresenterContext = deps.opportunityPresentation?.gatherPresenterContext ??
249
27
  gatherPresenterContext;
250
- const listOpportunities = defineTool({
251
- name: "list_opportunities",
252
- description: "Lists the authenticated user's actionable opportunities (discovered connections). Returns opportunity cards ready for display.\n\n" +
253
- "**What are opportunities?** Matches between users whose intents complement each other within shared networkes. " +
254
- "Each opportunity has a status: draft (not yet sent), pending (sent, awaiting response), accepted, rejected, or expired.\n\n" +
255
- "**What this returns:** Only draft and pending opportunities — the ones the user can still act on. " +
256
- "Accepted, rejected, and expired ones are not surfaced through this tool.\n\n" +
257
- "**When to use:** When the user wants to see their current matches or review what's waiting for their response.\n\n" +
258
- "**Returns:** Up to 3 opportunity code blocks (interactive cards) with counterpart name, match reasoning, confidence score, " +
259
- "and current status. Use update_opportunity to act on them (send, accept, reject).",
260
- querySchema: z.object({
261
- networkId: z
262
- .string()
263
- .optional()
264
- .describe("Network UUID to filter opportunities to a specific community. Get from read_networks. Defaults to the scoped network in network-scoped chats. Omit to see opportunities across all networks."),
265
- scopeType: z
266
- .enum(['intent'])
267
- .optional()
268
- .describe("Optional selected scope type. Use 'intent' to narrow listed opportunities to a selected intent."),
269
- scopeId: z
270
- .string()
271
- .optional()
272
- .describe("Selected intent UUID when scopeType is 'intent'. Ignored only when absent."),
273
- includeDigestMarkers: z
274
- .boolean()
275
- .optional()
276
- .describe("Internal scheduled-digest mode only. When true, includes hidden delivery markers so the digest send pass can confirm only edited-in opportunities."),
277
- }),
278
- handler: async ({ context, query }) => {
279
- const scopedNetworkId = focusedNetworkId(context) ?? context.networkId?.trim();
280
- const scopedIndexLabel = focusedNetworkLabel(context);
281
- // Strict scope enforcement: when chat is network-scoped, only allow that index
282
- if (scopedNetworkId &&
283
- query.networkId?.trim() &&
284
- query.networkId.trim() !== scopedNetworkId) {
285
- return error(`This chat is scoped to ${scopedIndexLabel}. You can only list opportunities from this community.`);
286
- }
287
- const effectiveIndexId = (scopedNetworkId || query.networkId?.trim()) ?? undefined;
288
- if (effectiveIndexId && !UUID_REGEX.test(effectiveIndexId)) {
289
- return error("Invalid network ID format.");
290
- }
291
- const contextIntentId = focusedIntentId(context);
292
- const rawScopeId = query.scopeId?.trim() || undefined;
293
- if (query.scopeType === 'intent' && !rawScopeId) {
294
- return error("scopeId required when scopeType is intent.");
295
- }
296
- if (!query.scopeType && rawScopeId) {
297
- return error("scopeType=intent required when scopeId is provided.");
298
- }
299
- if (rawScopeId && !UUID_REGEX.test(rawScopeId)) {
300
- return error("Invalid scope ID format.");
301
- }
302
- if (contextIntentId && rawScopeId && contextIntentId !== rawScopeId) {
303
- return error("This chat is scoped to a different intent.");
304
- }
305
- const effectiveIntentScope = contextIntentId
306
- ? { scopeType: 'intent', scopeId: contextIntentId }
307
- : query.scopeType === 'intent' && rawScopeId
308
- ? { scopeType: 'intent', scopeId: rawScopeId }
309
- : {};
310
- const selection = await selectOpportunityFeed({
311
- reader: database,
312
- deliveryLedger: deps.deliveryLedger,
313
- viewerId: context.userId,
314
- networkId: effectiveIndexId,
315
- intentScope: effectiveIntentScope,
316
- isMcp: context.isMcp === true,
317
- includeDigestMarkers: query.includeDigestMarkers,
318
- displayLimit: CHAT_DISPLAY_LIMIT,
319
- warn: (message, data) => logger.warn(message, data),
320
- });
321
- const { opportunities, dedupedCount, skippedIds, redeliveryIds, fetchedCount, isDigestMode } = selection;
322
- const buildListDebugSteps = () => {
323
- const steps = [];
324
- if (skippedIds.length > 0) {
325
- steps.push({
326
- step: "opportunity_display_skips",
327
- detail: `${skippedIds.length} opportunity card(s) couldn't be displayed`,
328
- data: {
329
- skippedCount: skippedIds.length,
330
- totalOpportunities: fetchedCount,
331
- skippedOpportunityIds: skippedIds,
332
- },
333
- });
334
- }
335
- return steps;
336
- };
337
- if (!opportunities || opportunities.length === 0) {
338
- if (skippedIds.length > 0) {
339
- const listDebugSteps = buildListDebugSteps();
340
- return success({
341
- found: false,
342
- count: 0,
343
- summary: "Some opportunities couldn't be displayed",
344
- message: "I found opportunities, but couldn't render them. Please try again.",
345
- ...(listDebugSteps.length ? { debugSteps: listDebugSteps } : {}),
346
- });
347
- }
348
- // Digest mode: distinguish "everything was already shown" from "nothing
349
- // exists" so the brief omits the people section instead of prompting
350
- // the user to run discovery.
351
- if (isDigestMode && dedupedCount > 0) {
352
- return success({
353
- found: false,
354
- count: 0,
355
- summary: "No new opportunities to show",
356
- message: "No new opportunities today — everything actionable has already been shown recently. Omit the people section from the digest.",
357
- });
358
- }
359
- return success({
360
- found: false,
361
- count: 0,
362
- summary: "No opportunities yet",
363
- 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.",
364
- });
365
- }
366
- // Batch-fetch profiles and users for all counterpart and introducer userIds to avoid N+1
367
- const counterpartUserIds = new Set();
368
- const introducerUserIds = new Set();
369
- for (const opp of opportunities) {
370
- const counterpartActor = opp.actors.find((a) => a.userId !== context.userId && a.role !== "introducer");
371
- if (counterpartActor?.userId)
372
- counterpartUserIds.add(counterpartActor.userId);
373
- const introducerActor = opp.actors.find((a) => a.role === "introducer" && a.userId !== context.userId);
374
- if (introducerActor?.userId)
375
- introducerUserIds.add(introducerActor.userId);
376
- }
377
- const allUserIds = [
378
- ...new Set([...counterpartUserIds, ...introducerUserIds]),
379
- ];
380
- const [profileResults, userResults] = await Promise.all([
381
- Promise.all(allUserIds.map((id) => database.getProfile(id))),
382
- Promise.all(allUserIds.map((id) => database.getUser(id))),
383
- ]);
384
- const profileMap = new Map();
385
- const userMap = new Map();
386
- allUserIds.forEach((userId, i) => {
387
- const profile = profileResults[i] ?? null;
388
- const user = userResults[i] ?? null;
389
- if (profile)
390
- profileMap.set(userId, profile);
391
- if (user)
392
- userMap.set(userId, user);
393
- });
394
- const cardDataList = [];
395
- const seenOpportunityIds = new Set();
396
- if (isDigestMode) {
397
- // ── Digest mode: use LLM presenter for rich, second-person card text ──
398
- const presenter = createOpportunityPresenter();
399
- const presenterDb = database;
400
- const PRESENTER_CONCURRENCY = 6;
401
- for (let i = 0; i < opportunities.length; i += PRESENTER_CONCURRENCY) {
402
- const chunk = opportunities.slice(i, i + PRESENTER_CONCURRENCY);
403
- const chunkCards = await Promise.all(chunk.map(async (opp) => {
404
- if (seenOpportunityIds.has(opp.id))
405
- return null;
406
- seenOpportunityIds.add(opp.id);
407
- try {
408
- const counterpartActor = opp.actors.find((a) => a.userId !== context.userId && a.role !== "introducer");
409
- const counterpartUserId = counterpartActor?.userId;
410
- if (!counterpartUserId)
411
- return null;
412
- const viewerIsIntroducerHere = opp.actors.some((a) => a.role === "introducer" && a.userId === context.userId);
413
- const secondPartyActorForHeadline = viewerIsIntroducerHere
414
- ? opp.actors.find((a) => a.userId !== context.userId &&
415
- a.userId !== counterpartUserId &&
416
- a.role !== "introducer")
417
- : undefined;
418
- const introducerActor = opp.actors.find((a) => a.role === "introducer" && a.userId !== context.userId);
419
- const createdByName = opp.detection.createdByName;
420
- const counterpartUser = userMap.get(counterpartUserId) ?? null;
421
- const counterpartName = profileMap.get(counterpartUserId)?.identity?.name ??
422
- counterpartUser?.name ??
423
- "Someone";
424
- const introducerName = createdByName ??
425
- (introducerActor
426
- ? (profileMap.get(introducerActor.userId)?.identity?.name ?? null)
427
- : null);
428
- const introducerUser = introducerActor
429
- ? userMap.get(introducerActor.userId) ?? null
430
- : null;
431
- const secondPartyUser = secondPartyActorForHeadline
432
- ? userMap.get(secondPartyActorForHeadline.userId) ?? null
433
- : null;
434
- const secondPartyNameForHeadline = secondPartyActorForHeadline
435
- ? (profileMap.get(secondPartyActorForHeadline.userId)?.identity?.name ??
436
- secondPartyUser?.name ??
437
- undefined)
438
- : undefined;
439
- const viewerActor = opp.actors.find((a) => a.userId === context.userId);
440
- const viewerRole = viewerActor?.role ?? "party";
441
- const isCounterpartGhost = counterpartUser?.isGhost ?? false;
442
- try {
443
- // Load the negotiation context alongside presenter context so
444
- // the digest copy can explain *why* the opportunity surfaced
445
- // (EDG-50) — the presenter grounds `digestSummary` in concrete
446
- // negotiation turns when this is present. The same context
447
- // yields the conversationId for the negotiation-trace link
448
- // (EDG-51).
449
- const [ctx, negotiationContext] = await Promise.all([
450
- gatherOpportunityPresenterContext(presenterDb, opp, context.userId, counterpartUserId),
451
- loadNegotiationContext(deps.negotiationDatabase, opp.id, opp.status),
452
- ]);
453
- const presentation = await presenter.presentCard({
454
- ...ctx,
455
- opportunityStatus: opp.status,
456
- ...(negotiationContext ? { negotiationContext } : {}),
457
- });
458
- const negotiationUrl = buildNegotiationUrl(negotiationContext?.conversationId, deps.frontendUrl);
459
- // Build narrator chip from presenter output
460
- let narratorChip;
461
- const introducerIsCounterpart = introducerActor && counterpartActor && introducerActor.userId === counterpartActor.userId;
462
- if (introducerActor && introducerActor.userId !== context.userId && !introducerIsCounterpart) {
463
- const narratorName = introducerName?.trim() || "Someone";
464
- narratorChip = {
465
- name: narratorName,
466
- text: stripLeadingNarratorName(presentation.narratorRemark, narratorName),
467
- avatar: introducerUser?.avatar ?? null,
468
- userId: introducerActor.userId,
469
- };
470
- }
471
- else if (introducerActor?.userId === context.userId) {
472
- narratorChip = { name: "You", text: presentation.narratorRemark, userId: context.userId };
473
- }
474
- else {
475
- narratorChip = { name: "Index", text: presentation.narratorRemark };
476
- }
477
- const card = {
478
- opportunityId: opp.id,
479
- userId: counterpartUserId,
480
- name: counterpartName,
481
- avatar: counterpartUser?.avatar ?? null,
482
- mainText: stripUuids(presentation.personalizedSummary),
483
- digestSummary: stripUuids(presentation.digestSummary),
484
- // Deep-link to the negotiation trace that produced this card
485
- // (EDG-51). Only present when a negotiation conversation exists.
486
- ...(negotiationUrl ? { negotiationUrl } : {}),
487
- cta: presentation.suggestedAction,
488
- headline: viewerIsIntroducerHere && secondPartyNameForHeadline
489
- ? `${counterpartName} → ${secondPartyNameForHeadline}`
490
- : presentation.headline,
491
- primaryActionLabel: getPrimaryActionLabel(viewerRole),
492
- secondaryActionLabel: SECONDARY_ACTION_LABEL,
493
- mutualIntentsLabel: presentation.mutualIntentsLabel,
494
- narratorChip,
495
- viewerRole,
496
- score: typeof opp.interpretation?.confidence === "number"
497
- ? opp.interpretation.confidence
498
- : undefined,
499
- status: opp.status,
500
- isGhost: isCounterpartGhost,
501
- ...(redeliveryIds.has(opp.id) ? { redelivery: true } : {}),
502
- ...(viewerIsIntroducerHere && secondPartyNameForHeadline
503
- ? {
504
- secondParty: {
505
- name: secondPartyNameForHeadline,
506
- ...(secondPartyUser?.avatar != null ? { avatar: secondPartyUser.avatar } : {}),
507
- ...(secondPartyActorForHeadline?.userId ? { userId: secondPartyActorForHeadline.userId } : {}),
508
- },
509
- }
510
- : {}),
511
- };
512
- // Attach the agent-facing profile and opportunity links for
513
- // MCP callers
514
- if (context.isMcp) {
515
- attachProfileLink(card, {
516
- counterpartUserId,
517
- frontendUrl: deps.frontendUrl,
518
- });
519
- attachOpportunityAppLink(card, {
520
- frontendUrl: deps.frontendUrl,
521
- });
522
- }
523
- return card;
524
- }
525
- catch (presenterErr) {
526
- logger.warn("LLM presenter failed for list_opportunities digest card, skipping raw fallback", {
527
- opportunityId: opp.id,
528
- err: presenterErr,
529
- });
530
- // Scheduled digests should only surface OpportunityPresenter-rendered
531
- // copy. The minimal fallback reuses evaluator reasoning, which can
532
- // contain raw narrator phrasing (for example "The discoverer...")
533
- // and is not suitable for AgentVillage morning briefs.
534
- skippedIds.push(opp.id);
535
- return null;
536
- }
537
- }
538
- catch (err) {
539
- logger.warn("Skipping opportunity that failed to build card", {
540
- opportunityId: opp.id,
541
- err,
542
- });
543
- skippedIds.push(opp.id);
544
- return null;
545
- }
546
- }));
547
- for (const card of chunkCards) {
548
- if (card)
549
- cardDataList.push(card);
550
- }
551
- }
552
- }
553
- else {
554
- // ── Chat/list mode: use OpportunityPresenter for user-facing card copy ──
555
- const presenter = createOpportunityPresenter();
556
- const presenterDb = database;
557
- const PRESENTER_CONCURRENCY = 6;
558
- for (let i = 0; i < opportunities.length; i += PRESENTER_CONCURRENCY) {
559
- const chunk = opportunities.slice(i, i + PRESENTER_CONCURRENCY);
560
- const chunkCards = await Promise.all(chunk.map(async (opp) => {
561
- if (seenOpportunityIds.has(opp.id))
562
- return null;
563
- seenOpportunityIds.add(opp.id);
564
- try {
565
- const counterpartActor = opp.actors.find((a) => a.userId !== context.userId && a.role !== "introducer");
566
- const counterpartUserId = counterpartActor?.userId;
567
- if (!counterpartUserId)
568
- return null;
569
- const viewerIsIntroducerHere = opp.actors.some((a) => a.role === "introducer" && a.userId === context.userId);
570
- const secondPartyActorForHeadline = viewerIsIntroducerHere
571
- ? opp.actors.find((a) => a.userId !== context.userId &&
572
- a.userId !== counterpartUserId &&
573
- a.role !== "introducer")
574
- : undefined;
575
- const introducerActor = opp.actors.find((a) => a.role === "introducer" && a.userId !== context.userId);
576
- const createdByName = opp.detection.createdByName;
577
- const counterpartProfile = profileMap.get(counterpartUserId) ?? null;
578
- const counterpartUser = userMap.get(counterpartUserId) ?? null;
579
- const introducerProfile = introducerActor && !createdByName
580
- ? profileMap.get(introducerActor.userId) ?? null
581
- : null;
582
- const counterpartName = counterpartProfile?.identity?.name ??
583
- counterpartUser?.name ??
584
- "Someone";
585
- const introducerName = createdByName ??
586
- (introducerActor ? introducerProfile?.identity?.name ?? null : null);
587
- const introducerUser = introducerActor
588
- ? userMap.get(introducerActor.userId) ?? null
589
- : null;
590
- const secondPartyUser = secondPartyActorForHeadline
591
- ? userMap.get(secondPartyActorForHeadline.userId) ?? null
592
- : null;
593
- const secondPartyNameForHeadline = secondPartyActorForHeadline
594
- ? (profileMap.get(secondPartyActorForHeadline.userId)?.identity?.name ??
595
- secondPartyUser?.name ??
596
- undefined)
597
- : undefined;
598
- const viewerActor = opp.actors.find((a) => a.userId === context.userId);
599
- const viewerRole = viewerActor?.role ?? "party";
600
- const isCounterpartGhost = counterpartUser?.isGhost ?? false;
601
- const [ctx, negotiationContext] = await Promise.all([
602
- gatherOpportunityPresenterContext(presenterDb, opp, context.userId, counterpartUserId),
603
- loadNegotiationContext(deps.negotiationDatabase, opp.id, opp.status),
604
- ]);
605
- const presentation = await presenter.presentCard({
606
- ...ctx,
607
- opportunityStatus: opp.status,
608
- ...(negotiationContext ? { negotiationContext } : {}),
609
- });
610
- let narratorChip;
611
- const introducerIsCounterpart = introducerActor && counterpartActor && introducerActor.userId === counterpartActor.userId;
612
- if (introducerActor && introducerActor.userId !== context.userId && !introducerIsCounterpart) {
613
- const narratorName = introducerName?.trim() || "Someone";
614
- narratorChip = {
615
- name: narratorName,
616
- text: stripLeadingNarratorName(presentation.narratorRemark, narratorName),
617
- avatar: introducerUser?.avatar ?? null,
618
- userId: introducerActor.userId,
619
- };
620
- }
621
- else if (introducerActor?.userId === context.userId) {
622
- narratorChip = { name: "You", text: presentation.narratorRemark, userId: context.userId };
623
- }
624
- else {
625
- narratorChip = { name: "Index", text: presentation.narratorRemark };
626
- }
627
- const cardData = {
628
- opportunityId: opp.id,
629
- userId: counterpartUserId,
630
- name: counterpartName,
631
- avatar: counterpartUser?.avatar ?? null,
632
- mainText: stripUuids(presentation.personalizedSummary),
633
- cta: presentation.suggestedAction,
634
- headline: viewerIsIntroducerHere && secondPartyNameForHeadline
635
- ? `${counterpartName} → ${secondPartyNameForHeadline}`
636
- : presentation.headline,
637
- primaryActionLabel: getPrimaryActionLabel(viewerRole),
638
- secondaryActionLabel: SECONDARY_ACTION_LABEL,
639
- mutualIntentsLabel: presentation.mutualIntentsLabel,
640
- narratorChip,
641
- viewerRole,
642
- score: typeof opp.interpretation?.confidence === "number"
643
- ? opp.interpretation.confidence
644
- : undefined,
645
- status: opp.status,
646
- isGhost: isCounterpartGhost,
647
- ...(viewerIsIntroducerHere && secondPartyNameForHeadline
648
- ? {
649
- secondParty: {
650
- name: secondPartyNameForHeadline,
651
- ...(secondPartyUser?.avatar != null ? { avatar: secondPartyUser.avatar } : {}),
652
- ...(secondPartyActorForHeadline?.userId ? { userId: secondPartyActorForHeadline.userId } : {}),
653
- },
654
- }
655
- : {}),
656
- };
657
- // For MCP callers, attach the agent-facing profile link and the
658
- // opportunity deep link so the agent never has to fabricate
659
- // either. Accepting still happens in the Index app behind an
660
- // authenticated call — the deep link only opens the card.
661
- if (context.isMcp) {
662
- attachProfileLink(cardData, {
663
- counterpartUserId,
664
- frontendUrl: deps.frontendUrl,
665
- });
666
- attachOpportunityAppLink(cardData, {
667
- frontendUrl: deps.frontendUrl,
668
- });
669
- }
670
- return cardData;
671
- }
672
- catch (err) {
673
- logger.warn("Skipping opportunity that failed to build presenter card", {
674
- opportunityId: opp.id,
675
- err,
676
- });
677
- skippedIds.push(opp.id);
678
- return null;
679
- }
680
- }));
681
- for (const card of chunkCards) {
682
- if (card)
683
- cardDataList.push(card);
684
- }
685
- }
686
- }
687
- const listDebugSteps = buildListDebugSteps();
688
- if (cardDataList.length === 0) {
689
- if (skippedIds.length > 0) {
690
- return success({
691
- found: false,
692
- count: 0,
693
- summary: "Some opportunities couldn't be displayed",
694
- message: "I found opportunities, but couldn't render them. Please try again.",
695
- ...(listDebugSteps.length ? { debugSteps: listDebugSteps } : {}),
696
- });
697
- }
698
- return success({
699
- found: false,
700
- count: 0,
701
- summary: "No opportunities yet",
702
- 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.",
703
- });
704
- }
705
- return success({
706
- found: true,
707
- count: cardDataList.length,
708
- summary: `You have ${cardDataList.length} opportunity(ies)`,
709
- message: buildOpportunityPresentation(cardDataList, {
710
- isMcp: context.isMcp ?? false,
711
- leadIn: `You have ${cardDataList.length} opportunity(ies).`,
712
- includeDigestMarkers: context.isMcp === true && query.includeDigestMarkers === true,
713
- }),
714
- ...(listDebugSteps.length ? { debugSteps: listDebugSteps } : {}),
715
- });
716
- },
717
- });
28
+ const listOpportunities = createListOpportunitiesTool(defineTool, deps);
718
29
  const updateOpportunity = defineTool({
719
30
  name: "update_opportunity",
720
31
  description: "Updates an opportunity's status, advancing it through the connection lifecycle.\n\n" +
@@ -885,12 +196,17 @@ export function createOpportunityTools(defineTool, deps) {
885
196
  const _updateGraphStart = Date.now();
886
197
  const _updateTraceEmitter = requestContext.getStore()?.traceEmitter;
887
198
  _updateTraceEmitter?.({ type: "graph_start", name: "opportunity" });
888
- const result = await invokeWithAbortSignal(graphs.opportunity, {
889
- userId: context.userId,
890
- operationMode: isSend ? "send" : "update",
891
- opportunityId: query.opportunityId,
892
- ...(isSend ? {} : { newStatus: query.status }),
893
- });
199
+ const operations = deps.opportunityOperations ?? { sendOpportunity, updateOpportunityStatus };
200
+ const result = isSend
201
+ ? await operations.sendOpportunity(deps, {
202
+ userId: context.userId,
203
+ opportunityId: query.opportunityId,
204
+ })
205
+ : await operations.updateOpportunityStatus(deps, {
206
+ userId: context.userId,
207
+ opportunityId: query.opportunityId,
208
+ newStatus: query.status,
209
+ });
894
210
  const _updateGraphMs = Date.now() - _updateGraphStart;
895
211
  _updateTraceEmitter?.({ type: "graph_end", name: "opportunity", durationMs: _updateGraphMs });
896
212
  if (result.mutationResult) {
@@ -903,7 +219,8 @@ export function createOpportunityTools(defineTool, deps) {
903
219
  ...(result.mutationResult.conversationId && {
904
220
  conversationId: result.mutationResult.conversationId,
905
221
  }),
906
- _graphTimings: [{ name: 'opportunity', durationMs: _updateGraphMs, agents: result.agentTimings ?? [] }],
222
+ // Neither update nor send invokes an agent, so this stage never reports sub-agent timings.
223
+ _graphTimings: [{ name: 'opportunity', durationMs: _updateGraphMs, agents: [] }],
907
224
  });
908
225
  }
909
226
  return error(result.mutationResult.error || "Failed to update opportunity.");