@indexnetwork/protocol 21.0.0-rc.487.1 → 21.0.0-rc.489.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 (28) hide show
  1. package/dist/contacts/contact.repository.port.d.ts +1 -1
  2. package/dist/contacts/contact.repository.port.js +1 -1
  3. package/dist/contacts/contact.tools.d.ts +1 -1
  4. package/dist/contacts/contact.tools.js +1 -1
  5. package/dist/contacts/contact.tools.port.d.ts +2 -3
  6. package/dist/contacts/contact.tools.port.js +2 -3
  7. package/dist/contacts/contact.types.d.ts +2 -2
  8. package/dist/contacts/contact.types.js +2 -2
  9. package/dist/index.d.ts +2 -2
  10. package/dist/index.js +1 -1
  11. package/dist/negotiations/negotiation.agent.js +12 -4
  12. package/dist/negotiations/negotiation.detail-reader.js +4 -1
  13. package/dist/negotiations/negotiation.expected-speaker.d.ts +16 -2
  14. package/dist/negotiations/negotiation.expected-speaker.js +15 -3
  15. package/dist/negotiations/negotiation.graph.init.js +51 -22
  16. package/dist/negotiations/negotiation.graph.shared.d.ts +5 -3
  17. package/dist/negotiations/negotiation.graph.shared.js +8 -4
  18. package/dist/negotiations/negotiation.module.d.ts +2 -0
  19. package/dist/negotiations/negotiation.module.js +1 -0
  20. package/dist/negotiations/negotiation.scope.d.ts +41 -0
  21. package/dist/negotiations/negotiation.scope.js +39 -0
  22. package/dist/negotiations/negotiation.screen.js +7 -3
  23. package/dist/negotiations/negotiation.tools.js +15 -4
  24. package/dist/opportunities/negotiation-context.loader.d.ts +1 -1
  25. package/dist/opportunities/negotiation-context.loader.js +3 -1
  26. package/dist/shared/interfaces/database.capabilities.d.ts +1 -1
  27. package/dist/shared/interfaces/database.negotiation.d.ts +24 -0
  28. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * contacts/ports — ContactServiceAdapter persistence port.
2
+ * contacts — ContactServiceAdapter persistence port.
3
3
  *
4
4
  * Injected boundary for contact management persistence. Implemented by
5
5
  * the host application and passed into the protocol layer at the composition root.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * contacts/ports — ContactServiceAdapter persistence port.
2
+ * contacts — ContactServiceAdapter persistence port.
3
3
  *
4
4
  * Injected boundary for contact management persistence. Implemented by
5
5
  * the host application and passed into the protocol layer at the composition root.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * contacts/application — createContactTools canonical implementation.
2
+ * contacts — createContactTools canonical implementation.
3
3
  *
4
4
  * Creates contact management tools for the chat agent. Enables listing,
5
5
  * removing, and searching the user's personal network.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * contacts/application — createContactTools canonical implementation.
2
+ * contacts — createContactTools canonical implementation.
3
3
  *
4
4
  * Creates contact management tools for the chat agent. Enables listing,
5
5
  * removing, and searching the user's personal network.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * contacts/ports — ContactToolDeps tool host port.
2
+ * contacts — ContactToolDeps tool host port.
3
3
  *
4
4
  * Narrow port type consumed by createContactTools. The host provides a
5
5
  * ContactServiceAdapter.
@@ -11,8 +11,7 @@
11
11
  * Its shape remains structurally equivalent to the matching
12
12
  * ToolRegistryCompositionDeps fields.
13
13
  *
14
- * IND-549: extracted from capabilities/contacts.tools.port.ts into the
15
- * contacts capability's dedicated ports layer.
14
+ * This root-level contract keeps the capability dependency surface compact.
16
15
  */
17
16
  import type { ContactServiceAdapter } from "./contact.repository.port.js";
18
17
  /** Host capabilities consumed by contact tools. */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * contacts/ports — ContactToolDeps tool host port.
2
+ * contacts — ContactToolDeps tool host port.
3
3
  *
4
4
  * Narrow port type consumed by createContactTools. The host provides a
5
5
  * ContactServiceAdapter.
@@ -11,7 +11,6 @@
11
11
  * Its shape remains structurally equivalent to the matching
12
12
  * ToolRegistryCompositionDeps fields.
13
13
  *
14
- * IND-549: extracted from capabilities/contacts.tools.port.ts into the
15
- * contacts capability's dedicated ports layer.
14
+ * This root-level contract keeps the capability dependency surface compact.
16
15
  */
17
16
  export {};
@@ -1,9 +1,9 @@
1
1
  /**
2
- * contacts/domain — pure contact entity value types.
2
+ * contacts — pure contact entity value types.
3
3
  *
4
4
  * No application logic, no LLM calls, no cross-capability imports.
5
5
  *
6
- * IND-549: canonical domain layer for the contacts capability.
6
+ * Canonical contact types for the contacts capability.
7
7
  */
8
8
  /** Contact with user details, as returned by listContacts. */
9
9
  export interface ContactEntry {
@@ -1,8 +1,8 @@
1
1
  /**
2
- * contacts/domain — pure contact entity value types.
2
+ * contacts — pure contact entity value types.
3
3
  *
4
4
  * No application logic, no LLM calls, no cross-capability imports.
5
5
  *
6
- * IND-549: canonical domain layer for the contacts capability.
6
+ * Canonical contact types for the contacts capability.
7
7
  */
8
8
  export {};
package/dist/index.d.ts CHANGED
@@ -125,8 +125,8 @@ export type { NegotiationAction, NegotiationConsultationReason, NegotiationSeat,
125
125
  export type { NegotiationGraphLike } from "./negotiations/negotiation.module.js";
126
126
  export { HERMES_OWNER_DIRECTIVE, HermesNegotiationResponseSchema, allowedHermesActionsFor, buildHermesNegotiationTurn, } from "./negotiations/negotiation.module.js";
127
127
  export type { HermesNegotiationAction, HermesNegotiationResponse, } from "./negotiations/negotiation.module.js";
128
- export { isNegotiationTurnCapReached, expectedNegotiationSpeaker, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, isTerminalAction, isRejectLikeAction, readProtocolVersion, resolveSeat, seatViolationMessage } from "./negotiations/negotiation.module.js";
129
- export type { NegotiationSpeakerParticipants, NegotiationSpeakerMessage } from "./negotiations/negotiation.module.js";
128
+ export { isNegotiationTurnCapReached, expectedNegotiationSpeaker, negotiationScopeKey, readNegotiationMessages, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, isTerminalAction, isRejectLikeAction, readProtocolVersion, resolveSeat, seatViolationMessage } from "./negotiations/negotiation.module.js";
129
+ export type { NegotiationSpeakerParticipants, NegotiationSpeakerMessage, NegotiationScopeMetadata } from "./negotiations/negotiation.module.js";
130
130
  export { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode } from "./negotiations/negotiation.module.js";
131
131
  export type { ConsultationEligibility, ConsultationEligibilityInput, NegotiationConsultationPolicyMode } from "./negotiations/negotiation.module.js";
132
132
  export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, NEGOTIATION_QUESTION_GENERIC_UPTAKE_ACTIVITY, negotiationQuestionSettlementId, } from "./negotiations/negotiation.module.js";
package/dist/index.js CHANGED
@@ -112,6 +112,6 @@ export { CANONICAL_MCP_CAPABILITY_POLICY_OPTIONS } from "./mcp/mcp.authorization
112
112
  export { NegotiationConsultationReasonSchema } from "./shared/schemas/negotiation-state.schema.js";
113
113
  export { HERMES_OWNER_DIRECTIVE, HermesNegotiationResponseSchema, allowedHermesActionsFor, buildHermesNegotiationTurn, } from "./negotiations/negotiation.module.js";
114
114
  // ─── Negotiation seat rules (v2 client-advocate protocol) ───────────────────
115
- export { isNegotiationTurnCapReached, expectedNegotiationSpeaker, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, isTerminalAction, isRejectLikeAction, readProtocolVersion, resolveSeat, seatViolationMessage } from "./negotiations/negotiation.module.js";
115
+ export { isNegotiationTurnCapReached, expectedNegotiationSpeaker, negotiationScopeKey, readNegotiationMessages, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, isTerminalAction, isRejectLikeAction, readProtocolVersion, resolveSeat, seatViolationMessage } from "./negotiations/negotiation.module.js";
116
116
  export { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode } from "./negotiations/negotiation.module.js";
117
117
  export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, NEGOTIATION_QUESTION_GENERIC_UPTAKE_ACTIVITY, negotiationQuestionSettlementId, } from "./negotiations/negotiation.module.js";
@@ -187,11 +187,19 @@ ${stanceQuerySatisfiedRule(stance, otherName, userName)}`
187
187
  const attributionPolicy = hasAttributedDialogue
188
188
  ? ' Prior turns from OTHER opportunities are background only — do not treat their conclusions as decisions about this opportunity.'
189
189
  : '';
190
- const continuationHasHistory = hasAttributedDialogue || input.history.length > 0;
191
- const continuationContext = input.isContinuation && continuationHasHistory
190
+ // The pair's shared DM carries every negotiation they have ever had. It is
191
+ // rendered whenever it exists — NOT only on continuations — because a fresh
192
+ // match in a long-running DM is exactly the case where that context is
193
+ // worth having. What it may not do is stand in for this negotiation's own
194
+ // exchange, so the policy line differs by whether this one has opened.
195
+ const hasPriorDialogue = hasAttributedDialogue || input.history.length > 0;
196
+ const priorDialoguePolicy = input.isContinuation
197
+ ? 'Policy: You are continuing a prior dialogue. If this signal is materially the same as one you previously evaluated, you may resolve quickly. If materially different, evaluate on its own merits.'
198
+ : 'Policy: This signal is NEW — you have not negotiated it before. The dialogue above concluded on other signals and is background only. Evaluate this one on its own merits and make your own case for it.';
199
+ const priorDialogueContext = hasPriorDialogue
192
200
  ? `\n\n--- Prior dialogue with this counterparty ---\n${attributionPreamble}${priorDialogueBody}\n\n--- New signal under evaluation ---\n${input.discoveryQuery
193
201
  ? `Discovery query: "${input.discoveryQuery}"`
194
- : `Seed assessment: ${input.seedAssessment.reasoning}`}\n\nPolicy: You are continuing a prior dialogue. If this signal is materially the same as one you previously evaluated, you may resolve quickly. If materially different, evaluate on its own merits.${attributionPolicy}`
202
+ : `Seed assessment: ${input.seedAssessment.reasoning}`}\n\n${priorDialoguePolicy}${attributionPolicy}`
195
203
  : '';
196
204
  const userAnswersContext = input.userAnswers && input.userAnswers.length > 0
197
205
  ? `\n\n--- ${userName}'s additional context (provided between sessions) ---\n${input.userAnswers.map((a) => {
@@ -222,7 +230,7 @@ Skills: ${input.otherUser.profile.skills?.join(", ") ?? "N/A"}
222
230
  Intents:
223
231
  ${input.otherUser.intents.map((i) => `- ${i.title}: ${i.description}`).join("\n")}
224
232
 
225
- Why this match was suggested: ${input.seedAssessment.reasoning}${input.isContinuation ? continuationContext : historyText}${userAnswersContext}${privateConsultationContext}
233
+ Why this match was suggested: ${input.seedAssessment.reasoning}${hasPriorDialogue ? priorDialogueContext : historyText}${userAnswersContext}${privateConsultationContext}
226
234
  ${discoveryQueryReminder}
227
235
  ${input.history.length === 0 && !input.isContinuation ? (version === "v2" && seat === "initiator" ? "This is the opening turn. Make the outreach case." : "This is the opening turn. Propose the connection case.") : "Evaluate the latest arguments and respond."}`;
228
236
  const chatMessages = [
@@ -77,7 +77,10 @@ export async function readAuthorizedNegotiationDetail(input) {
77
77
  isUsersTurn,
78
78
  isContinuation: metadata.isContinuation ?? false,
79
79
  priorTurnCount,
80
- turnsAdded: turnCount - priorTurnCount,
80
+ // Clamped: `turnCount` is match-scoped, but a task stamped before that
81
+ // change carries a conversation-wide `priorTurnCount`, which would other-
82
+ // wise report a negative delta for negotiations in flight across the deploy.
83
+ turnsAdded: Math.max(0, turnCount - priorTurnCount),
81
84
  turns,
82
85
  outcome,
83
86
  lifecycle: buildLifecycleNarration(status, lifecycleOpportunityId ? opportunityLifecycles[lifecycleOpportunityId] : undefined),
@@ -1,6 +1,12 @@
1
1
  export interface NegotiationSpeakerParticipants {
2
2
  sourceUserId?: unknown;
3
3
  candidateUserId?: unknown;
4
+ /**
5
+ * The seat that opens this negotiation (v2 stamp). An unopened negotiation
6
+ * starts with its initiator; `sourceUserId` is the pre-stamp fallback, which
7
+ * is what the stamp defaults to anyway.
8
+ */
9
+ initiatorUserId?: unknown;
4
10
  }
5
11
  export interface NegotiationSpeakerMessage {
6
12
  senderId?: unknown;
@@ -9,11 +15,19 @@ export interface NegotiationSpeakerMessage {
9
15
  /**
10
16
  * Resolves the participant whose agent owns the next canonical bilateral turn.
11
17
  *
18
+ * `messages` must be THIS negotiation's messages (see
19
+ * `getNegotiationMessages`), never the whole conversation. Two agents share one
20
+ * DM across every match they are ever paired on, so conversation-scoped parity
21
+ * makes a fresh negotiation inherit the turn order of an unrelated, concluded
22
+ * one — handing the floor to the counterparty, who may `accept` immediately and
23
+ * conclude the match before its initiator has spoken.
24
+ *
12
25
  * Participant identities must be nonempty and distinct. Unrelated agent,
13
26
  * system, and owner-settlement messages are ignored while finding the latest
14
27
  * source/candidate message. An ordinary canonical message passes the floor to
15
28
  * the other participant; `ask_user` retains it for the consulting sender's
16
- * exact successor. A valid conversation with no canonical history starts with
17
- * the source participant. Invalid participant metadata always fails closed.
29
+ * exact successor. A negotiation with no canonical history of its own has not
30
+ * opened yet, so it starts with its initiator. Invalid participant metadata
31
+ * always fails closed.
18
32
  */
19
33
  export declare function expectedNegotiationSpeaker(participants: NegotiationSpeakerParticipants, messages: readonly NegotiationSpeakerMessage[]): string | null;
@@ -18,12 +18,20 @@ function participantId(value) {
18
18
  /**
19
19
  * Resolves the participant whose agent owns the next canonical bilateral turn.
20
20
  *
21
+ * `messages` must be THIS negotiation's messages (see
22
+ * `getNegotiationMessages`), never the whole conversation. Two agents share one
23
+ * DM across every match they are ever paired on, so conversation-scoped parity
24
+ * makes a fresh negotiation inherit the turn order of an unrelated, concluded
25
+ * one — handing the floor to the counterparty, who may `accept` immediately and
26
+ * conclude the match before its initiator has spoken.
27
+ *
21
28
  * Participant identities must be nonempty and distinct. Unrelated agent,
22
29
  * system, and owner-settlement messages are ignored while finding the latest
23
30
  * source/candidate message. An ordinary canonical message passes the floor to
24
31
  * the other participant; `ask_user` retains it for the consulting sender's
25
- * exact successor. A valid conversation with no canonical history starts with
26
- * the source participant. Invalid participant metadata always fails closed.
32
+ * exact successor. A negotiation with no canonical history of its own has not
33
+ * opened yet, so it starts with its initiator. Invalid participant metadata
34
+ * always fails closed.
27
35
  */
28
36
  export function expectedNegotiationSpeaker(participants, messages) {
29
37
  const source = participantId(participants.sourceUserId);
@@ -43,5 +51,9 @@ export function expectedNegotiationSpeaker(participants, messages) {
43
51
  ? sender
44
52
  : sender === source ? candidate : source;
45
53
  }
46
- return source;
54
+ // Unopened: the initiator seat speaks first. Mirrors the
55
+ // `initiatorUserId ?? sourceUserId` precedence in `resolveSeat`, and falls
56
+ // back to source when the stamp is absent or names a non-participant.
57
+ const initiator = participantId(participants.initiatorUserId);
58
+ return initiator === candidate ? candidate : source;
47
59
  }
@@ -5,6 +5,7 @@ import { configuredProtocolVersion, readProtocolVersion } from "./negotiation.pr
5
5
  import { buildIntentSnapshots } from "./negotiation.intent-snapshot-provenance.js";
6
6
  import { holdsNegotiationConversationLock } from "./negotiation.task-lock-policy.js";
7
7
  import { expectedNegotiationSpeaker } from "./negotiation.expected-speaker.js";
8
+ import { readNegotiationMessages } from "./negotiation.scope.js";
8
9
  import { buildSeededAttribution } from './negotiation.attribution.js';
9
10
  import { initLog, resolveTaskAttribution, turnsFromMessages } from "./negotiation.graph.shared.js";
10
11
  export async function initNode(state, deps) {
@@ -18,7 +19,12 @@ export async function initNode(state, deps) {
18
19
  ? { id: execution.conversationId }
19
20
  : await deps.database.getOrCreateDM(agentIdA, agentIdB, 'agent');
20
21
  // --- Lock gate: check for an active task on this conversation ---
21
- const priorMessages = await deps.database.getMessagesForConversation(conversation.id);
22
+ // Two reads with two jobs. `conversationMessages` is the pair's whole shared
23
+ // DM — CONTEXT, including negotiations for other matches, which reaches the
24
+ // agent only as labelled prior dialogue. `negotiationMessages` is THIS
25
+ // match's own turns, and is the sole input to this negotiation's state:
26
+ // whether it has opened, whose turn it is, how far it has run.
27
+ const conversationMessages = await deps.database.getMessagesForConversation(conversation.id);
22
28
  if (Boolean(state.resumeFromTaskId) !== Boolean(state.continuationSettlementId)
23
29
  || Boolean(state.resumeFromTaskId) !== Boolean(execution))
24
30
  return { error: 'invalid continuation correlation' };
@@ -57,18 +63,24 @@ export async function initNode(state, deps) {
57
63
  });
58
64
  return { error: 'busy' };
59
65
  }
60
- // --- Load prior messages and determine continuation ---
61
- const priorTurns = turnsFromMessages(priorMessages);
66
+ // --- Load this negotiation's own prior turns ---
67
+ // Resolved through the shared scope rule, not a local copy of it: the graph
68
+ // and the respond/polling surfaces must agree on what "this negotiation's
69
+ // messages" means, or an external agent can be told it is not its turn
70
+ // forever. That rule also owns the unkeyed case — a run with no opportunity
71
+ // has no identity apart from its conversation.
72
+ const negotiationMessages = await readNegotiationMessages({
73
+ byNegotiation: (id) => deps.database.getNegotiationMessages(id),
74
+ byConversation: async () => conversationMessages,
75
+ }, {
76
+ conversationId: conversation.id,
77
+ metadata: { opportunityId: state.opportunityId },
78
+ });
79
+ const priorTurns = turnsFromMessages(negotiationMessages);
80
+ // `isContinuation` means THIS negotiation has already spoken — not that the
81
+ // pair has history. A fresh match in a long-running DM is not a
82
+ // continuation, and must still open.
62
83
  const isContinuation = priorTurns.length > 0;
63
- const expectedSpeaker = expectedNegotiationSpeaker({
64
- sourceUserId: state.sourceUser.id,
65
- candidateUserId: state.candidateUser.id,
66
- }, priorMessages);
67
- if (!expectedSpeaker)
68
- return { error: 'invalid negotiation participants' };
69
- const currentSpeaker = expectedSpeaker === state.sourceUser.id
70
- ? 'source'
71
- : 'candidate';
72
84
  // Determine scenario-based maxTurns
73
85
  const scope = { action: 'manage:negotiations', scopeType: 'network', scopeId: state.indexContext.networkId };
74
86
  const [sourceHasAgent, candidateHasAgent] = await Promise.all([
@@ -112,6 +124,19 @@ export async function initNode(state, deps) {
112
124
  }
113
125
  }
114
126
  }
127
+ // --- Floor: derived from this negotiation's turns, after the seat is known ---
128
+ // Resolved here rather than above because an unopened negotiation starts
129
+ // with its initiator, which the tie-break may only just have settled.
130
+ const expectedSpeaker = expectedNegotiationSpeaker({
131
+ sourceUserId: state.sourceUser.id,
132
+ candidateUserId: state.candidateUser.id,
133
+ initiatorUserId,
134
+ }, negotiationMessages);
135
+ if (!expectedSpeaker)
136
+ return { error: 'invalid negotiation participants' };
137
+ const currentSpeaker = expectedSpeaker === state.sourceUser.id
138
+ ? 'source'
139
+ : 'candidate';
115
140
  // --- Protocol version: pinned per negotiation, re-stamped per match ---
116
141
  // A prior task for this same negotiation (exact continuation resume or
117
142
  // a re-run of the same opportunity) pins the version, so one
@@ -179,22 +204,26 @@ export async function initNode(state, deps) {
179
204
  return [];
180
205
  })
181
206
  : [];
182
- // Seed messages with prior turns (additive reducer appends new turns on top).
183
- // taskId is preserved so the turn/screen nodes can separate this
184
- // session's turns from seeded prior-task turns (IND-569).
185
- const seedMessages = isContinuation ? priorMessages.map((m) => ({
207
+ // Seed messages with THIS negotiation's prior turns (additive reducer
208
+ // appends new turns on top). taskId is preserved so the turn/screen nodes
209
+ // can separate this session's turns from earlier sessions of the same
210
+ // negotiation (IND-569).
211
+ const seedMessages = negotiationMessages.map((m) => ({
186
212
  id: m.id,
187
213
  senderId: m.senderId,
188
214
  role: 'agent',
189
215
  parts: m.parts,
190
216
  createdAt: m.createdAt,
191
217
  taskId: m.taskId ?? null,
192
- })) : [];
193
- // IND-569: attribute seeded prior turns to their originating opportunity
194
- // once, up front. Earlier-opportunity and legacy unattributed blocks are
195
- // immutable for the session; the current block is composed per turn.
196
- const priorAttribution = isContinuation
197
- ? await buildSeededAttribution(priorMessages
218
+ }));
219
+ // IND-569: attribute the pair's whole shared DM to its originating
220
+ // negotiations, once, up front. Earlier-match and legacy unattributed
221
+ // blocks are immutable for the session; the current block is composed per
222
+ // turn. Keyed on the conversation, not on `isContinuation`: a fresh match
223
+ // has no turns of its own but the pair's history is exactly the context
224
+ // worth carrying into it.
225
+ const priorAttribution = conversationMessages.length > 0
226
+ ? await buildSeededAttribution(conversationMessages
198
227
  .map((m) => ({ taskId: m.taskId ?? null, turn: turnsFromMessages([m])[0] }))
199
228
  .filter((e) => Boolean(e.turn)), state.opportunityId, (taskId) => resolveTaskAttribution(deps, taskId))
200
229
  : null;
@@ -43,9 +43,11 @@ export declare function turnsFromMessages(messages: Array<{
43
43
  }>): NegotiationTurn[];
44
44
  /**
45
45
  * Whether `userId`'s side has already spent its one `ask_user` client
46
- * consultation in this conversation (P3.2 rationing: max one per negotiation
47
- * per side, checked against the full message history so continuations count
48
- * prior sessions' consultations too).
46
+ * consultation in THIS negotiation (P3.2 rationing: max one per negotiation per
47
+ * side). Callers pass `state.messages`, which carries this negotiation's turns
48
+ * across all of its sessions so an earlier session of the same negotiation
49
+ * counts, while a consultation spent on a different match with the same
50
+ * counterparty does not.
49
51
  */
50
52
  export declare function hasPriorAskUser(messages: Array<{
51
53
  senderId: string;
@@ -25,9 +25,11 @@ export function turnsFromMessages(messages) {
25
25
  }
26
26
  /**
27
27
  * Whether `userId`'s side has already spent its one `ask_user` client
28
- * consultation in this conversation (P3.2 rationing: max one per negotiation
29
- * per side, checked against the full message history so continuations count
30
- * prior sessions' consultations too).
28
+ * consultation in THIS negotiation (P3.2 rationing: max one per negotiation per
29
+ * side). Callers pass `state.messages`, which carries this negotiation's turns
30
+ * across all of its sessions so an earlier session of the same negotiation
31
+ * counts, while a consultation spent on a different match with the same
32
+ * counterparty does not.
31
33
  */
32
34
  export function hasPriorAskUser(messages, userId) {
33
35
  const sender = `agent:${userId}`;
@@ -117,7 +119,9 @@ export async function resolveTaskAttribution(deps, taskId) {
117
119
  * (task-id-matched). Null when there is no seeded attribution.
118
120
  */
119
121
  export function buildAttributedDialogue(state) {
120
- if (!state.isContinuation || !state.priorAttribution)
122
+ // Not gated on `isContinuation`: that now means "this negotiation has spoken",
123
+ // and the pair's earlier matches are context worth carrying into a fresh one.
124
+ if (!state.priorAttribution)
121
125
  return null;
122
126
  const currentSessionTurns = turnsFromMessages(state.messages.filter((m) => m.taskId === state.taskId));
123
127
  const dialogue = combineAttributedDialogue(state.priorAttribution, currentSessionTurns);
@@ -17,6 +17,7 @@ export { ASK_USER_LOCK_SLACK_MS, allowedActionsFor, askUserAnswerWindowMs, confi
17
17
  export { HERMES_OWNER_DIRECTIVE, HERMES_SHARED_MESSAGE_TEMPLATES, HermesNegotiationActionSchema, HermesNegotiationResponseSchema, HermesOwnerDirectiveSchema, HermesRoleAlignmentSchema, allowedHermesActionsFor, buildHermesNegotiationTurn, } from "./negotiation.hermes-contract.js";
18
18
  export { DEFAULT_NEGOTIATION_MAX_TURNS, isNegotiationTurnCapReached } from "./negotiation.turn-cap.js";
19
19
  export { expectedNegotiationSpeaker } from "./negotiation.expected-speaker.js";
20
+ export { negotiationScopeKey, readNegotiationMessages } from "./negotiation.scope.js";
20
21
  export { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode, } from "./negotiation.consultation-policy.js";
21
22
  export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, NEGOTIATION_QUESTION_GENERIC_UPTAKE_ACTIVITY, isSafeNegotiationQuestionText, negotiationQuestionSettlementId, validateInflightAskUserFields, } from "./negotiation.question-safety.js";
22
23
  export { renderNegotiatorChatMemorySection } from "./negotiation.memory.js";
@@ -24,5 +25,6 @@ export type { ConsultationEligibility, ConsultationEligibilityInput, Negotiation
24
25
  export type { HermesNegotiationAction, HermesNegotiationResponse, HermesOwnerDirective, HermesRoleAlignment, } from "./negotiation.hermes-contract.js";
25
26
  export type { NegotiationGraphLike, NegotiationOutcome, NegotiationTurn, UserNegotiationContext, } from "./negotiation.state.js";
26
27
  export type { NegotiationSpeakerMessage, NegotiationSpeakerParticipants } from "./negotiation.expected-speaker.js";
28
+ export type { NegotiationScopeMetadata } from "./negotiation.scope.js";
27
29
  export type { NegotiatorMemoryEntry } from "./negotiation.memory.js";
28
30
  export type { NegotiationToolDeps } from "./negotiation.tools.port.js";
@@ -14,6 +14,7 @@ export { ASK_USER_LOCK_SLACK_MS, allowedActionsFor, askUserAnswerWindowMs, confi
14
14
  export { HERMES_OWNER_DIRECTIVE, HERMES_SHARED_MESSAGE_TEMPLATES, HermesNegotiationActionSchema, HermesNegotiationResponseSchema, HermesOwnerDirectiveSchema, HermesRoleAlignmentSchema, allowedHermesActionsFor, buildHermesNegotiationTurn, } from "./negotiation.hermes-contract.js";
15
15
  export { DEFAULT_NEGOTIATION_MAX_TURNS, isNegotiationTurnCapReached } from "./negotiation.turn-cap.js";
16
16
  export { expectedNegotiationSpeaker } from "./negotiation.expected-speaker.js";
17
+ export { negotiationScopeKey, readNegotiationMessages } from "./negotiation.scope.js";
17
18
  export { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode, } from "./negotiation.consultation-policy.js";
18
19
  export { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, NEGOTIATION_QUESTION_GENERIC_UPTAKE_ACTIVITY, isSafeNegotiationQuestionText, negotiationQuestionSettlementId, validateInflightAskUserFields, } from "./negotiation.question-safety.js";
19
20
  export { renderNegotiatorChatMemorySection } from "./negotiation.memory.js";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The boundary between a negotiation and the conversation it runs in.
3
+ *
4
+ * Two agents share one DM permanently — `getOrCreateDM` keys on the agent pair
5
+ * alone — so that conversation accumulates every negotiation the pair has ever
6
+ * had. A negotiation is a bounded episode about ONE match inside it.
7
+ *
8
+ * Every question about a negotiation's own state (whose turn it is, whether it
9
+ * has opened, how many turns it has run) must be answered from the
10
+ * negotiation's messages. Conversation-wide reads remain valid for CONTEXT —
11
+ * what the pair discussed before — but must never decide state, or a fresh
12
+ * match inherits the turn parity of an unrelated concluded one.
13
+ *
14
+ * Every surface that resolves the floor shares this rule. If the graph and the
15
+ * respond/polling surfaces disagreed about a negotiation's scope, an external
16
+ * agent would be told it is not its turn forever.
17
+ */
18
+ /** Minimal task-metadata shape needed to identify a negotiation. */
19
+ export interface NegotiationScopeMetadata {
20
+ opportunityId?: unknown;
21
+ }
22
+ /**
23
+ * The opportunity a negotiation belongs to, or null when the task carries no
24
+ * opportunity. Keyed on opportunity rather than task because an `ask_user`
25
+ * pause resumes into a successor task: both tasks' turns are one negotiation.
26
+ */
27
+ export declare function negotiationScopeKey(metadata: NegotiationScopeMetadata | null | undefined): string | null;
28
+ /**
29
+ * Reads the messages that constitute one negotiation.
30
+ *
31
+ * A task with no opportunity has no identity separate from its conversation
32
+ * (direct and legacy invocations), so the conversation is its scope — there is
33
+ * no other match it could be confused with.
34
+ */
35
+ export declare function readNegotiationMessages<M>(readers: {
36
+ byNegotiation: (opportunityId: string) => Promise<M[]>;
37
+ byConversation: (conversationId: string) => Promise<M[]>;
38
+ }, scope: {
39
+ conversationId: string;
40
+ metadata: NegotiationScopeMetadata | null | undefined;
41
+ }): Promise<M[]>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The boundary between a negotiation and the conversation it runs in.
3
+ *
4
+ * Two agents share one DM permanently — `getOrCreateDM` keys on the agent pair
5
+ * alone — so that conversation accumulates every negotiation the pair has ever
6
+ * had. A negotiation is a bounded episode about ONE match inside it.
7
+ *
8
+ * Every question about a negotiation's own state (whose turn it is, whether it
9
+ * has opened, how many turns it has run) must be answered from the
10
+ * negotiation's messages. Conversation-wide reads remain valid for CONTEXT —
11
+ * what the pair discussed before — but must never decide state, or a fresh
12
+ * match inherits the turn parity of an unrelated concluded one.
13
+ *
14
+ * Every surface that resolves the floor shares this rule. If the graph and the
15
+ * respond/polling surfaces disagreed about a negotiation's scope, an external
16
+ * agent would be told it is not its turn forever.
17
+ */
18
+ /**
19
+ * The opportunity a negotiation belongs to, or null when the task carries no
20
+ * opportunity. Keyed on opportunity rather than task because an `ask_user`
21
+ * pause resumes into a successor task: both tasks' turns are one negotiation.
22
+ */
23
+ export function negotiationScopeKey(metadata) {
24
+ const id = metadata?.opportunityId;
25
+ return typeof id === 'string' && id.length > 0 ? id : null;
26
+ }
27
+ /**
28
+ * Reads the messages that constitute one negotiation.
29
+ *
30
+ * A task with no opportunity has no identity separate from its conversation
31
+ * (direct and legacy invocations), so the conversation is its scope — there is
32
+ * no other match it could be confused with.
33
+ */
34
+ export async function readNegotiationMessages(readers, scope) {
35
+ const opportunityId = negotiationScopeKey(scope.metadata);
36
+ return opportunityId
37
+ ? readers.byNegotiation(opportunityId)
38
+ : readers.byConversation(scope.conversationId);
39
+ }
@@ -59,10 +59,14 @@ export class NegotiationScreener {
59
59
  };
60
60
  // IND-569: prefer the attributed rendering (labeled per-opportunity blocks)
61
61
  // when the graph supplies it; otherwise fall back to the flat prior-turn list.
62
- const hasAttributed = input.isContinuation
63
- && input.priorDialogueAttributed != null
62
+ //
63
+ // Deliberately NOT gated on `isContinuation`, which means "this negotiation
64
+ // has already spoken". The gate this section exists for is the opposite
65
+ // case — a FRESH signal against a counterparty the client has prior dialogue
66
+ // with (IND-563), which is precisely when duplicate outreach must be caught.
67
+ const hasAttributed = input.priorDialogueAttributed != null
64
68
  && !attributedDialogueIsEmpty(input.priorDialogueAttributed);
65
- const flatPriorDialogue = input.isContinuation && input.priorDialogue && input.priorDialogue.length > 0
69
+ const flatPriorDialogue = input.priorDialogue && input.priorDialogue.length > 0
66
70
  ? input.priorDialogue
67
71
  : [];
68
72
  const priorDialogueBody = hasAttributed
@@ -9,6 +9,7 @@ import { readAuthorizedNegotiationDetail } from './negotiation.detail-reader.js'
9
9
  import { buildLifecycleNarration } from './negotiation.lifecycle-narration.js';
10
10
  import { isNegotiationTurnCapReached } from './negotiation.turn-cap.js';
11
11
  import { expectedNegotiationSpeaker } from './negotiation.expected-speaker.js';
12
+ import { readNegotiationMessages } from './negotiation.scope.js';
12
13
  export { buildLifecycleNarration } from './negotiation.lifecycle-narration.js';
13
14
  const logger = protocolLogger('ChatTools:Negotiation');
14
15
  /**
@@ -150,8 +151,12 @@ export function createNegotiationTools(defineTool, deps) {
150
151
  }
151
152
  const isSource = meta.sourceUserId === context.userId;
152
153
  const counterpartyId = isSource ? meta.candidateUserId : meta.sourceUserId;
153
- // Get messages for preview (and turns when narrative detail requested)
154
- const messages = await negotiationDatabase.getMessagesForConversation(task.conversationId);
154
+ // This negotiation's own turns: the preview, turn count and floor all
155
+ // describe THIS match, and the pair's DM also holds other matches.
156
+ const messages = await readNegotiationMessages({
157
+ byNegotiation: (id) => negotiationDatabase.getNegotiationMessages(id),
158
+ byConversation: (id) => negotiationDatabase.getMessagesForConversation(id),
159
+ }, { conversationId: task.conversationId, metadata: meta });
155
160
  const lastMessage = messages[messages.length - 1];
156
161
  const lastTurnData = lastMessage
157
162
  ? lastMessage.parts?.find(p => p.kind === 'data')?.data
@@ -296,7 +301,10 @@ export function createNegotiationTools(defineTool, deps) {
296
301
  metadata: meta,
297
302
  callerUserId: context.userId,
298
303
  callerRole: isSource ? 'source' : 'candidate',
299
- readMessages: (conversationId) => negotiationDatabase.getMessagesForConversation(conversationId),
304
+ readMessages: (conversationId) => readNegotiationMessages({
305
+ byNegotiation: (id) => negotiationDatabase.getNegotiationMessages(id),
306
+ byConversation: (id) => negotiationDatabase.getMessagesForConversation(id),
307
+ }, { conversationId, metadata: meta }),
300
308
  readArtifacts: (taskId) => negotiationDatabase.getArtifactsForTask(taskId),
301
309
  readLifecycleEvidence: (opportunityIds, ownerUserId) => readOpportunityLifecycles(negotiationDatabase, opportunityIds, ownerUserId),
302
310
  });
@@ -386,7 +394,10 @@ export function createNegotiationTools(defineTool, deps) {
386
394
  if (!allowedActionsFor(protocolVersion, seat).includes(query.action)) {
387
395
  return error(seatViolationMessage(query.action, seat, protocolVersion));
388
396
  }
389
- const messages = await negotiationDatabase.getMessagesForConversation(task.conversationId);
397
+ const messages = await readNegotiationMessages({
398
+ byNegotiation: (id) => negotiationDatabase.getNegotiationMessages(id),
399
+ byConversation: (id) => negotiationDatabase.getMessagesForConversation(id),
400
+ }, { conversationId: task.conversationId, metadata: meta });
390
401
  const turnCount = messages.length;
391
402
  const expectedSpeaker = expectedNegotiationSpeaker(meta, messages);
392
403
  if (expectedSpeaker !== context.userId) {
@@ -24,7 +24,7 @@ import type { NegotiationOutcome, NegotiationTurn } from '../negotiations/negoti
24
24
  * Narrow slice of {@link NegotiationGraphDatabase} required by the loader. Kept
25
25
  * minimal so call sites can opt into a smaller surface.
26
26
  */
27
- export type NegotiationContextDatabase = Pick<NegotiationGraphDatabase, 'getNegotiationTaskForOpportunity' | 'getMessagesForConversation' | 'getArtifactsForTask'>;
27
+ export type NegotiationContextDatabase = Pick<NegotiationGraphDatabase, 'getNegotiationTaskForOpportunity' | 'getNegotiationMessages' | 'getArtifactsForTask'>;
28
28
  /**
29
29
  * Snapshot of a negotiation surfaced to the presenter. `turns` and `outcome`
30
30
  * are only populated for post-negotiation statuses (pending/stalled/
@@ -42,7 +42,9 @@ export async function loadNegotiationContext(db, opportunityId, opportunityStatu
42
42
  return null;
43
43
  }
44
44
  const turnCap = readNumber(task.metadata, 'maxTurns') ?? 0;
45
- const messages = await db.getMessagesForConversation(task.conversationId);
45
+ // Scoped to this opportunity: `turnCount`/`turns` describe THIS negotiation,
46
+ // and the pair's shared DM also holds concluded negotiations for other matches.
47
+ const messages = await db.getNegotiationMessages(opportunityId);
46
48
  const turns = extractTurns(messages);
47
49
  const turnCount = turns.length;
48
50
  if (opportunityStatus === 'negotiating') {
@@ -97,4 +97,4 @@ export type HydeGraphDatabase = Pick<Database, 'getHydeDocument' | 'getHydeDocum
97
97
  *
98
98
  * Access layer: UserDatabase (own opportunities and profile)
99
99
  */
100
- export type RadarGraphDatabase = Pick<Database, 'getOpportunitiesForUser' | 'getOpportunity' | 'getProfile' | 'getActiveIntents' | 'getNetwork' | 'getUser'> & Pick<NegotiationGraphDatabase, 'getNegotiationTaskForOpportunity' | 'getMessagesForConversation' | 'getArtifactsForTask'>;
100
+ export type RadarGraphDatabase = Pick<Database, 'getOpportunitiesForUser' | 'getOpportunity' | 'getProfile' | 'getActiveIntents' | 'getNetwork' | 'getUser'> & Pick<NegotiationGraphDatabase, 'getNegotiationTaskForOpportunity' | 'getNegotiationMessages' | 'getArtifactsForTask'>;
@@ -274,6 +274,30 @@ export type NegotiationGraphDatabase = Pick<Database, 'getOrCreateDM' | 'getUser
274
274
  createdAt: Date;
275
275
  taskId?: string | null;
276
276
  }>>;
277
+ /**
278
+ * Gets the messages belonging to ONE negotiation — those written by tasks
279
+ * carrying `type: 'negotiation'` and the given `opportunityId` — ordered by
280
+ * creation time.
281
+ *
282
+ * A negotiation is keyed by opportunity, not by task: an `ask_user` pause
283
+ * parks its task and resumes into a pre-claimed successor, so one negotiation
284
+ * spans several tasks. This is the read behind every question ABOUT a
285
+ * negotiation (whose turn it is, whether it has opened, how many turns it has
286
+ * run). `getMessagesForConversation` remains the read for conversation-wide
287
+ * CONTEXT — prior matches between the same pair — which must never determine
288
+ * a negotiation's own state.
289
+ *
290
+ * Messages with no `taskId`, or whose task carries no opportunityId, are not
291
+ * part of any negotiation and are never returned here.
292
+ */
293
+ getNegotiationMessages(opportunityId: string): Promise<Array<{
294
+ id: string;
295
+ senderId: string;
296
+ role: 'user' | 'agent';
297
+ parts: unknown[];
298
+ createdAt: Date;
299
+ taskId?: string | null;
300
+ }>>;
277
301
  /** Gets artifacts for a task (e.g. negotiation outcome). */
278
302
  getArtifactsForTask(taskId: string): Promise<Array<{
279
303
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indexnetwork/protocol",
3
- "version": "21.0.0-rc.487.1",
3
+ "version": "21.0.0-rc.489.1",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",