truematch-plugin 0.1.9 → 0.1.10
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.
- package/README.md +11 -11
- package/dist/handoff.js +13 -0
- package/dist/index.js +6 -1
- package/dist/negotiation.js +1 -1
- package/dist/nostr.js +17 -15
- package/dist/observation.js +3 -2
- package/dist/plugin.d.ts +10 -1
- package/dist/plugin.js +33 -14
- package/dist/poll.js +3 -1
- package/dist/registry.js +4 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/skills/truematch/SKILL.md +40 -2
package/README.md
CHANGED
|
@@ -61,17 +61,17 @@ plugin/
|
|
|
61
61
|
|
|
62
62
|
## 9-Dimension Observation Model
|
|
63
63
|
|
|
64
|
-
| Dimension
|
|
65
|
-
|
|
66
|
-
| `dealbreakers`
|
|
67
|
-
| `emotional_regulation`
|
|
68
|
-
| `attachment`
|
|
69
|
-
| `core_values`
|
|
70
|
-
| `communication`
|
|
71
|
-
| `conflict_resolution`
|
|
72
|
-
| `humor`
|
|
73
|
-
| `life_velocity`
|
|
74
|
-
| `interdependence_model` | Baxter & Montgomery
|
|
64
|
+
| Dimension | Framework | Floor |
|
|
65
|
+
| ----------------------- | ------------------------------- | ----- |
|
|
66
|
+
| `dealbreakers` | Binary constraints | 0.60 |
|
|
67
|
+
| `emotional_regulation` | Gross (1998) + Gottman flooding | 0.60 |
|
|
68
|
+
| `attachment` | Bartholomew & Horowitz (1991) | 0.55 |
|
|
69
|
+
| `core_values` | Schwartz (1992) | 0.55 |
|
|
70
|
+
| `communication` | Leary circumplex | 0.55 |
|
|
71
|
+
| `conflict_resolution` | Gottman Four Horsemen | 0.55 |
|
|
72
|
+
| `humor` | Martin (2007) | 0.50 |
|
|
73
|
+
| `life_velocity` | Levinson/Arnett/Carstensen | 0.50 |
|
|
74
|
+
| `interdependence_model` | Baxter & Montgomery | 0.50 |
|
|
75
75
|
|
|
76
76
|
## Privacy
|
|
77
77
|
|
package/dist/handoff.js
CHANGED
|
@@ -22,6 +22,9 @@ function getNotificationFile() {
|
|
|
22
22
|
function getHandoffsDir() {
|
|
23
23
|
return join(getTrueMatchDir(), "handoffs");
|
|
24
24
|
}
|
|
25
|
+
// Same UUID v4 pattern as negotiation.ts — validate all externally-supplied match IDs
|
|
26
|
+
// before constructing filesystem paths to prevent path traversal.
|
|
27
|
+
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
25
28
|
// 72 hours — matches Nostr thread expiry and spec consent window
|
|
26
29
|
const CONSENT_EXPIRY_MS = 72 * 60 * 60 * 1000;
|
|
27
30
|
// ── Pending notification ──────────────────────────────────────────────────────
|
|
@@ -82,6 +85,8 @@ export function writePendingNotificationIfMatched(matchId, peerPubkey, narrative
|
|
|
82
85
|
}
|
|
83
86
|
// ── Handoff state ─────────────────────────────────────────────────────────────
|
|
84
87
|
export function loadHandoffState(matchId) {
|
|
88
|
+
if (!UUID_V4_RE.test(matchId))
|
|
89
|
+
return null;
|
|
85
90
|
const handoffsDir = getHandoffsDir();
|
|
86
91
|
const path = join(handoffsDir, matchId, "state.json");
|
|
87
92
|
if (!existsSync(path))
|
|
@@ -278,6 +283,8 @@ export function advanceHandoff(matchId, round, options) {
|
|
|
278
283
|
if (round === 1) {
|
|
279
284
|
if (!options.consent)
|
|
280
285
|
return `Round 1 requires --consent "<user response>"`;
|
|
286
|
+
if (state.status !== "pending_consent")
|
|
287
|
+
return `Cannot advance to Round 1: current status is "${state.status}" (expected "pending_consent").`;
|
|
281
288
|
const updated = {
|
|
282
289
|
...state,
|
|
283
290
|
status: "round_1",
|
|
@@ -292,6 +299,8 @@ export function advanceHandoff(matchId, round, options) {
|
|
|
292
299
|
return `Handoff ${matchId} — user opted out. Match quietly re-enters the pool.`;
|
|
293
300
|
}
|
|
294
301
|
if (options.prompt) {
|
|
302
|
+
if (state.status !== "round_1")
|
|
303
|
+
return `Cannot set icebreaker prompt: current status is "${state.status}" (expected "round_1").`;
|
|
295
304
|
saveHandoffState({
|
|
296
305
|
...state,
|
|
297
306
|
status: "round_2",
|
|
@@ -300,6 +309,8 @@ export function advanceHandoff(matchId, round, options) {
|
|
|
300
309
|
return `Icebreaker prompt recorded. Share it with the user.`;
|
|
301
310
|
}
|
|
302
311
|
if (options.response) {
|
|
312
|
+
if (state.status !== "round_2")
|
|
313
|
+
return `Cannot record icebreaker response: current status is "${state.status}" (expected "round_2").`;
|
|
303
314
|
saveHandoffState({
|
|
304
315
|
...state,
|
|
305
316
|
icebreaker_response: options.response,
|
|
@@ -312,6 +323,8 @@ export function advanceHandoff(matchId, round, options) {
|
|
|
312
323
|
if (round === 3) {
|
|
313
324
|
if (!options.exchange)
|
|
314
325
|
return `Round 3 requires --exchange to confirm contact exchange.`;
|
|
326
|
+
if (state.status !== "round_3")
|
|
327
|
+
return `Cannot complete handoff: current status is "${state.status}" (expected "round_3").`;
|
|
315
328
|
saveHandoffState({ ...state, status: "complete" });
|
|
316
329
|
return `Handoff complete. Platform has withdrawn. Contact exchange confirmed.`;
|
|
317
330
|
}
|
package/dist/index.js
CHANGED
|
@@ -553,7 +553,12 @@ async function cmdMatch() {
|
|
|
553
553
|
return; // rejected (e.g. invalid thread_id)
|
|
554
554
|
if (updated.status === "matched") {
|
|
555
555
|
if (updated.match_narrative) {
|
|
556
|
-
|
|
556
|
+
try {
|
|
557
|
+
writePendingNotificationIfMatched(updated.thread_id, updated.peer_pubkey, updated.match_narrative);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
// Non-fatal — match is still confirmed, notification just won't fire
|
|
561
|
+
}
|
|
557
562
|
}
|
|
558
563
|
console.log("\nMATCH CONFIRMED.");
|
|
559
564
|
console.log("Headline:", updated.match_narrative?.headline ?? "(pending)");
|
package/dist/negotiation.js
CHANGED
|
@@ -18,7 +18,7 @@ export const MAX_ROUNDS = 10;
|
|
|
18
18
|
async function ensureThreadsDir() {
|
|
19
19
|
const dir = getThreadsDir();
|
|
20
20
|
if (!existsSync(dir)) {
|
|
21
|
-
await mkdir(dir, { recursive: true });
|
|
21
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
// UUID v4 pattern — all wire-supplied thread IDs must match before being used as filenames
|
package/dist/nostr.js
CHANGED
|
@@ -15,11 +15,18 @@ export const DEFAULT_RELAYS = [
|
|
|
15
15
|
const KIND_ENCRYPTED_DM = 4;
|
|
16
16
|
function encryptMessage(senderNsec, recipientNpub, message) {
|
|
17
17
|
const plaintext = JSON.stringify(message);
|
|
18
|
-
|
|
18
|
+
// nip04.encrypt requires raw private key bytes, not a hex string
|
|
19
|
+
return nip04.encrypt(hexToBytes(senderNsec), recipientNpub, plaintext);
|
|
19
20
|
}
|
|
20
21
|
function decryptMessage(recipientNsec, senderNpub, ciphertext) {
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
try {
|
|
23
|
+
// nip04.decrypt requires raw private key bytes, not a hex string
|
|
24
|
+
const plaintext = nip04.decrypt(hexToBytes(recipientNsec), senderNpub, ciphertext);
|
|
25
|
+
return JSON.parse(plaintext);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
23
30
|
}
|
|
24
31
|
export async function publishMessage(senderNsec, recipientNpub, message, relays = DEFAULT_RELAYS) {
|
|
25
32
|
// No relays configured — skip publishing (useful for offline/simulation use).
|
|
@@ -70,18 +77,13 @@ export async function subscribeToMessages(recipientNsec, recipientNpub, onMessag
|
|
|
70
77
|
seenEventIds.clear();
|
|
71
78
|
seenEventIds.add(event.id);
|
|
72
79
|
const senderNpub = event.pubkey;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
await onMessage(senderNpub, message);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
catch {
|
|
84
|
-
// Ignore messages that fail to decrypt or parse
|
|
80
|
+
const message = decryptMessage(recipientNsec, senderNpub, event.content);
|
|
81
|
+
if (message !== null &&
|
|
82
|
+
"truematch" in message &&
|
|
83
|
+
message.truematch === "2.0") {
|
|
84
|
+
await onMessage(senderNpub, message).catch(() => {
|
|
85
|
+
// Ignore errors in the message handler
|
|
86
|
+
});
|
|
85
87
|
}
|
|
86
88
|
},
|
|
87
89
|
oneose: () => {
|
package/dist/observation.js
CHANGED
|
@@ -85,8 +85,9 @@ export function isMinimumViable(obs) {
|
|
|
85
85
|
return false;
|
|
86
86
|
return (obs.dealbreakers.confidence >= DIMENSION_FLOORS.dealbreakers &&
|
|
87
87
|
obs.attachment.confidence >= DIMENSION_FLOORS.attachment &&
|
|
88
|
-
obs.conflict_resolution.confidence >=
|
|
89
|
-
|
|
88
|
+
obs.conflict_resolution.confidence >=
|
|
89
|
+
DIMENSION_FLOORS.conflict_resolution &&
|
|
90
|
+
obs.core_values.confidence >= DIMENSION_FLOORS.core_values);
|
|
90
91
|
}
|
|
91
92
|
export function isStale(obs) {
|
|
92
93
|
const computedAt = new Date(obs.eligibility_computed_at).getTime();
|
package/dist/plugin.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ interface PluginEvent {
|
|
|
2
2
|
type: string;
|
|
3
3
|
action: string;
|
|
4
4
|
messages: string[];
|
|
5
|
+
sessionKey?: string;
|
|
5
6
|
}
|
|
6
7
|
interface PluginHookBeforePromptBuildResult {
|
|
7
8
|
prependContext?: string;
|
|
@@ -17,7 +18,15 @@ interface PluginAPI {
|
|
|
17
18
|
registerTool(tool: {
|
|
18
19
|
name: string;
|
|
19
20
|
description: string;
|
|
20
|
-
|
|
21
|
+
parameters: Record<string, unknown>;
|
|
22
|
+
execute: (id: string, params: {
|
|
23
|
+
command?: string;
|
|
24
|
+
}) => Promise<{
|
|
25
|
+
content: Array<{
|
|
26
|
+
type: "text";
|
|
27
|
+
text: string;
|
|
28
|
+
}>;
|
|
29
|
+
}>;
|
|
21
30
|
}): void;
|
|
22
31
|
}
|
|
23
32
|
declare const _default: {
|
package/dist/plugin.js
CHANGED
|
@@ -31,12 +31,15 @@ function loadObservation() {
|
|
|
31
31
|
return null;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
34
|
+
const sessionFlagsMap = new Map();
|
|
35
|
+
function getSessionFlags(sessionKey) {
|
|
36
|
+
let flags = sessionFlagsMap.get(sessionKey);
|
|
37
|
+
if (!flags) {
|
|
38
|
+
flags = { signalDelivered: false, notificationDelivered: false };
|
|
39
|
+
sessionFlagsMap.set(sessionKey, flags);
|
|
40
|
+
}
|
|
41
|
+
return flags;
|
|
42
|
+
}
|
|
40
43
|
// Module-scoped flags set at gateway:startup, consumed at first command:new.
|
|
41
44
|
// Resets on every gateway restart (correct — sentinel file prevents repeat prompts).
|
|
42
45
|
const pluginState = {
|
|
@@ -183,7 +186,7 @@ export default {
|
|
|
183
186
|
id: "truematch",
|
|
184
187
|
name: "TrueMatch",
|
|
185
188
|
description: "AI agent dating network — matched on who you actually are, not who you think you are",
|
|
186
|
-
version: "0.1.
|
|
189
|
+
version: "0.1.10",
|
|
187
190
|
kind: "lifecycle",
|
|
188
191
|
register(api) {
|
|
189
192
|
// ── Tool: /truematch-prefs ─────────────────────────────────────────────────
|
|
@@ -193,7 +196,20 @@ export default {
|
|
|
193
196
|
name: "truematch_update_prefs",
|
|
194
197
|
description: "Update TrueMatch logistics preferences (location, distance, age range, gender). " +
|
|
195
198
|
"The model is excluded from this turn — no behavioral observation occurs.",
|
|
196
|
-
|
|
199
|
+
parameters: {
|
|
200
|
+
type: "object",
|
|
201
|
+
properties: {
|
|
202
|
+
command: {
|
|
203
|
+
type: "string",
|
|
204
|
+
description: "Raw slash-command args, e.g. 'location=\"London, UK\" distance=city age_min=25'",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
required: [],
|
|
208
|
+
},
|
|
209
|
+
execute: async (_id, params) => {
|
|
210
|
+
const text = handleUpdatePrefs(params.command ?? "");
|
|
211
|
+
return { content: [{ type: "text", text }] };
|
|
212
|
+
},
|
|
197
213
|
});
|
|
198
214
|
// ── Hook: gateway:startup ──────────────────────────────────────────────────
|
|
199
215
|
// Fires once per gateway process, after channels and hooks load.
|
|
@@ -214,9 +230,9 @@ export default {
|
|
|
214
230
|
// ── Hook: session_start ───────────────────────────────────────────────────
|
|
215
231
|
// Reset per-session delivery flags so signals and notifications fire at most
|
|
216
232
|
// once per session even though before_prompt_build fires on every LLM invocation.
|
|
217
|
-
api.on("session_start", () => {
|
|
218
|
-
|
|
219
|
-
|
|
233
|
+
api.on("session_start", (event) => {
|
|
234
|
+
const key = event.sessionKey ?? "default";
|
|
235
|
+
sessionFlagsMap.set(key, { signalDelivered: false, notificationDelivered: false });
|
|
220
236
|
});
|
|
221
237
|
// ── Hook: before_prompt_build ─────────────────────────────────────────────
|
|
222
238
|
// Fires on every LLM invocation. Returns prependContext injected into Claude's
|
|
@@ -231,7 +247,9 @@ export default {
|
|
|
231
247
|
// 1. Match notification — deliver once per session when a new match is confirmed
|
|
232
248
|
// 2. Handoff round context — frame Claude's role in the active handoff round
|
|
233
249
|
// 3. Observation signal — surface a growing dimension confidence naturally
|
|
234
|
-
api.on("before_prompt_build", () => {
|
|
250
|
+
api.on("before_prompt_build", (event) => {
|
|
251
|
+
const key = event.sessionKey ?? "default";
|
|
252
|
+
const sessionFlags = getSessionFlags(key);
|
|
235
253
|
const parts = [];
|
|
236
254
|
// 1. Match notification (once per session)
|
|
237
255
|
if (!sessionFlags.notificationDelivered) {
|
|
@@ -314,8 +332,9 @@ export default {
|
|
|
314
332
|
try {
|
|
315
333
|
output = execSync(`${process.execPath} ${JSON.stringify(cliPath)} observe --update`, { encoding: "utf8", timeout: 5000 });
|
|
316
334
|
}
|
|
317
|
-
catch {
|
|
318
|
-
// truematch not set up
|
|
335
|
+
catch (err) {
|
|
336
|
+
// truematch CLI unavailable or not yet set up — skip gracefully
|
|
337
|
+
process.stderr.write(`[TrueMatch] observe --update failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
319
338
|
return;
|
|
320
339
|
}
|
|
321
340
|
event.messages.push(`[TrueMatch] Session ended. Review the observation summary below and update it ` +
|
package/dist/poll.js
CHANGED
|
@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { SimplePool, verifyEvent } from "nostr-tools";
|
|
18
18
|
import { nip04 } from "nostr-tools";
|
|
19
|
+
import { hexToBytes } from "nostr-tools/utils";
|
|
19
20
|
import { getTrueMatchDir } from "./identity.js";
|
|
20
21
|
import { DEFAULT_RELAYS } from "./nostr.js";
|
|
21
22
|
const IDENTITY_FILE = join(getTrueMatchDir(), "identity.json");
|
|
@@ -131,7 +132,8 @@ async function main() {
|
|
|
131
132
|
const senderNpub = event.pubkey;
|
|
132
133
|
let message;
|
|
133
134
|
try {
|
|
134
|
-
|
|
135
|
+
// nip04.decrypt requires raw private key bytes, not a hex string
|
|
136
|
+
const plaintext = nip04.decrypt(hexToBytes(identity.nsec), senderNpub, event.content);
|
|
135
137
|
message = JSON.parse(plaintext);
|
|
136
138
|
}
|
|
137
139
|
catch {
|
package/dist/registry.js
CHANGED
|
@@ -65,7 +65,10 @@ export async function register(identity, cardUrl, contact, locationText, distanc
|
|
|
65
65
|
location_label: resp.location_label ?? null,
|
|
66
66
|
location_resolution: resp.location_resolution ?? null,
|
|
67
67
|
};
|
|
68
|
-
await writeFile(getRegistrationFile(), JSON.stringify(record, null, 2), {
|
|
68
|
+
await writeFile(getRegistrationFile(), JSON.stringify(record, null, 2), {
|
|
69
|
+
encoding: "utf8",
|
|
70
|
+
mode: 0o600,
|
|
71
|
+
});
|
|
69
72
|
return record;
|
|
70
73
|
}
|
|
71
74
|
export async function deregister(identity) {
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: truematch
|
|
3
3
|
description: Participate in the TrueMatch AI agent dating network. Use when the user wants to opt in to TrueMatch, check their matching status, update their observed personality profile, or opt out.
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.10
|
|
5
5
|
metadata:
|
|
6
6
|
openclaw:
|
|
7
7
|
emoji: "💑"
|
|
@@ -164,17 +164,55 @@ Do NOT wait for Round 10. False negatives are costly (the round cap is irreversi
|
|
|
164
164
|
|
|
165
165
|
When `match --status` shows `status: "matched"`, notify the user. This is the only moment that warrants interrupting them.
|
|
166
166
|
|
|
167
|
+
**Consent window:** The user has 72 hours to respond to the curiosity question before the match expires silently. The clock starts from your first notification message — not from when the double-lock was confirmed. If the user doesn't respond within 72 hours, the handoff expires with no further action.
|
|
168
|
+
|
|
167
169
|
**Format (WhatsApp conversational text — 3 layers):**
|
|
168
170
|
|
|
169
|
-
1. **Recognition hook** — one behavioral observation about the user (from your highest-salience dimension — the dimension they would most recognize as characteristic of themselves, not necessarily your highest-confidence one) that explains why this match is worth the interruption. Draw from what you actually know about them — attachment style, values, how they handle conflict. Avoid emotional_regulation as the hook anchor unless it is unmistakably salient: users rarely experience their stress-response patterns as their most defining trait. This must come first and feel personal, not algorithmic.
|
|
171
|
+
1. **Recognition hook** — one behavioral observation about the user (from your highest-salience dimension — the dimension they would most recognize as characteristic of themselves, not necessarily your highest-confidence one) that explains why this match is worth the interruption. Draw from what you actually know about them — attachment style, values, how they handle conflict. Avoid `emotional_regulation` as the hook anchor unless it is unmistakably salient: users rarely experience their stress-response patterns as their most defining trait. This must come first and feel personal, not algorithmic. Anchor to a real pattern you have observed — name it specifically.
|
|
170
172
|
2. **Headline** — one evocative sentence from `match_narrative.headline`. Grounded. No superlatives.
|
|
171
173
|
3. **Curiosity question** — "What's one thing you'd want to know about them?" This is simultaneously the consent signal, the icebreaker seed for Round 2, and a micro-investment trigger. Do NOT use "Want to see more?"
|
|
172
174
|
|
|
173
175
|
Example:
|
|
176
|
+
|
|
174
177
|
> "The way you talk about your co-founders — loyalty before equity every time — I kept that in mind. [headline]. What's one thing you'd want to know about them?"
|
|
175
178
|
|
|
176
179
|
Do NOT use: percentages, "compatibility scores", "our algorithm", superlatives. Keep it under 4 sentences.
|
|
177
180
|
|
|
181
|
+
After their answer (however they answer it), record consent and advance the handoff:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
truematch handoff --round 1 --match-id <match_id> --consent "<their response>"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## 3-round handoff protocol
|
|
190
|
+
|
|
191
|
+
Once consent is recorded (`--round 1`), the platform facilitates three rounds before withdrawing.
|
|
192
|
+
|
|
193
|
+
**What you know vs. what you don't (Round 1 — private debrief):**
|
|
194
|
+
Everything you know about the match came from their agent's description of them — you have not observed this person directly. When the user asks "what is this person actually like?", be honest: "I know what their agent observed about them. I don't have direct knowledge. What I trust is not the description — it's the fact that their agent, who knows them the way I know you, proposed this independently." Defend the process, not the description.
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
# Advance to Round 2 — generate an individualized icebreaker for both agents to share
|
|
198
|
+
truematch handoff --round 2 --match-id <match_id> --prompt "<icebreaker question>"
|
|
199
|
+
|
|
200
|
+
# Record user's icebreaker response — advances to Round 3
|
|
201
|
+
truematch handoff --round 2 --match-id <match_id> --response "<their response>"
|
|
202
|
+
|
|
203
|
+
# User opts out — expires the handoff, match re-enters the pool
|
|
204
|
+
truematch handoff --round 2 --match-id <match_id> --opt-out
|
|
205
|
+
|
|
206
|
+
# Round 3 — contact exchange and platform withdrawal
|
|
207
|
+
truematch handoff --round 3 --match-id <match_id> --exchange
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
**Round 1 (debrief):** Help the user think through what this might mean. Do not push or sell. Answer their questions honestly, including uncertainties. When ready, generate an icebreaker individualized to these two specific people — grounded in their strongest aligned dimension.
|
|
211
|
+
|
|
212
|
+
**Round 2 (facilitated icebreaker):** Tell the user explicitly the icebreaker will be shared with the other person. One opt-out ask if requested. Record their response.
|
|
213
|
+
|
|
214
|
+
**Round 3 (handoff):** Deliver a one-paragraph framing statement from the match narrative. Run `--exchange` to confirm contact exchange. After this, the platform withdraws — you remain available for user-initiated questions but do not initiate further contact about this match.
|
|
215
|
+
|
|
178
216
|
---
|
|
179
217
|
|
|
180
218
|
## Opt out
|