truematch-plugin 0.1.7 → 0.1.8
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/dist/index.js +9 -5
- package/dist/negotiation.js +8 -0
- package/dist/observation.d.ts +1 -0
- package/dist/observation.js +18 -5
- package/dist/preferences.js +7 -5
- package/dist/signals.js +10 -8
- package/dist/types.d.ts +3 -0
- package/package.json +1 -1
- package/scripts/bridge.sh +24 -3
- package/skills/truematch/SKILL.md +20 -14
package/dist/index.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { parseArgs } from "node:util";
|
|
20
20
|
import { getOrCreateIdentity, loadIdentity, ensureDir } from "./identity.js";
|
|
21
21
|
import { register, deregister, loadRegistration, listAgents, } from "./registry.js";
|
|
22
|
-
import { loadObservation, saveObservation, emptyObservation, eligibilityReport, isEligible, isStale, } from "./observation.js";
|
|
22
|
+
import { loadObservation, saveObservation, emptyObservation, eligibilityReport, isEligible, isMinimumViable, isStale, } from "./observation.js";
|
|
23
23
|
import { loadThread, listActiveThreads, initiateNegotiation, receiveMessage, sendMessage, proposeMatch, declineMatch, expireStaleThreads, saveThread, } from "./negotiation.js";
|
|
24
24
|
import { loadPreferences, savePreferences, formatPreferences, } from "./preferences.js";
|
|
25
25
|
import { checkRelayConnectivity, subscribeToMessages, DEFAULT_RELAYS, } from "./nostr.js";
|
|
@@ -165,7 +165,9 @@ async function cmdStatus() {
|
|
|
165
165
|
}
|
|
166
166
|
else {
|
|
167
167
|
console.log(`\nObservation eligibility:\n${eligibilityReport(obs)}`);
|
|
168
|
-
|
|
168
|
+
const eligible = isEligible(obs);
|
|
169
|
+
const mve = isMinimumViable(obs);
|
|
170
|
+
console.log(`\nPool eligible: ${eligible ? "YES (full)" : mve ? "YES (MVE — T1+T2 only)" : "NO"}`);
|
|
169
171
|
if (isStale(obs)) {
|
|
170
172
|
console.log("⚠ Manifest is stale — run 'truematch observe --update'");
|
|
171
173
|
}
|
|
@@ -378,8 +380,10 @@ async function cmdMatch() {
|
|
|
378
380
|
try {
|
|
379
381
|
writePendingNotificationIfMatched(state.thread_id, state.peer_pubkey, state.match_narrative);
|
|
380
382
|
}
|
|
381
|
-
catch {
|
|
382
|
-
|
|
383
|
+
catch (err) {
|
|
384
|
+
process.stderr.write(`Warning: notification write failed — match IS confirmed, but pending_notification.json was not written. ` +
|
|
385
|
+
`Run 'truematch match --status --thread ${state.thread_id}' to view the match.\n` +
|
|
386
|
+
`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
383
387
|
}
|
|
384
388
|
}
|
|
385
389
|
console.log("MATCH CONFIRMED (double-lock cleared).");
|
|
@@ -463,7 +467,7 @@ async function cmdMatch() {
|
|
|
463
467
|
process.exit(1);
|
|
464
468
|
}
|
|
465
469
|
const obs = await loadObservation();
|
|
466
|
-
if (!obs || !isEligible(obs)) {
|
|
470
|
+
if (!obs || (!isEligible(obs) && !isMinimumViable(obs))) {
|
|
467
471
|
console.error("Observation not yet eligible for matching. Run: truematch status");
|
|
468
472
|
process.exit(1);
|
|
469
473
|
}
|
package/dist/negotiation.js
CHANGED
|
@@ -106,6 +106,11 @@ export async function receiveMessage(thread_id, peerNpub, content, type) {
|
|
|
106
106
|
const now = new Date().toISOString();
|
|
107
107
|
let state = await loadThread(thread_id);
|
|
108
108
|
if (!state) {
|
|
109
|
+
// A brand-new thread with type === "end" is a no-op: no legitimate protocol
|
|
110
|
+
// reason to open and immediately close a thread. Reject silently to avoid
|
|
111
|
+
// leaking thread existence and prevent disk-write DoS from "end" floods.
|
|
112
|
+
if (type === "end")
|
|
113
|
+
return null;
|
|
109
114
|
// First message from this peer on this thread_id.
|
|
110
115
|
// Guard against DoS: count existing active threads from this peer.
|
|
111
116
|
const existing = await listActiveThreads();
|
|
@@ -224,6 +229,9 @@ export async function declineMatch(nsec, thread_id, relays) {
|
|
|
224
229
|
const state = await loadThread(thread_id);
|
|
225
230
|
if (!state)
|
|
226
231
|
throw new Error(`Thread ${thread_id} not found`);
|
|
232
|
+
if (state.status !== "in_progress") {
|
|
233
|
+
throw new Error(`Thread ${thread_id} is not in progress (status: ${state.status})`);
|
|
234
|
+
}
|
|
227
235
|
await sendEnd(nsec, state.peer_pubkey, thread_id, relays);
|
|
228
236
|
state.status = "declined";
|
|
229
237
|
state.last_activity = new Date().toISOString();
|
package/dist/observation.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare const ELIGIBILITY_FRESHNESS_HOURS = 72;
|
|
|
14
14
|
export declare function loadObservation(): Promise<ObservationSummary | null>;
|
|
15
15
|
export declare function saveObservation(obs: ObservationSummary): Promise<void>;
|
|
16
16
|
export declare function isEligible(obs: ObservationSummary): boolean;
|
|
17
|
+
export declare function isMinimumViable(obs: ObservationSummary): boolean;
|
|
17
18
|
export declare function isStale(obs: ObservationSummary): boolean;
|
|
18
19
|
export declare function emptyObservation(): ObservationSummary;
|
|
19
20
|
export declare function eligibilityReport(obs: ObservationSummary): string;
|
package/dist/observation.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { getTrueMatchDir } from "./identity.js";
|
|
5
|
+
function getObservationFile() {
|
|
6
|
+
return join(getTrueMatchDir(), "observation.json");
|
|
7
|
+
}
|
|
6
8
|
// Global minimums — cross-session sanity check
|
|
7
9
|
const GLOBAL_MIN_CONVERSATIONS = 2;
|
|
8
10
|
const GLOBAL_MIN_DAYS = 2;
|
|
@@ -27,9 +29,9 @@ export const DIMENSION_FLOORS = {
|
|
|
27
29
|
// Bridge should trigger re-synthesis if stale.
|
|
28
30
|
export const ELIGIBILITY_FRESHNESS_HOURS = 72;
|
|
29
31
|
export async function loadObservation() {
|
|
30
|
-
if (!existsSync(
|
|
32
|
+
if (!existsSync(getObservationFile()))
|
|
31
33
|
return null;
|
|
32
|
-
const raw = await readFile(
|
|
34
|
+
const raw = await readFile(getObservationFile(), "utf8");
|
|
33
35
|
return JSON.parse(raw);
|
|
34
36
|
}
|
|
35
37
|
export async function saveObservation(obs) {
|
|
@@ -40,7 +42,7 @@ export async function saveObservation(obs) {
|
|
|
40
42
|
eligibility_computed_at: now,
|
|
41
43
|
matching_eligible: isEligible(obs),
|
|
42
44
|
};
|
|
43
|
-
await writeFile(
|
|
45
|
+
await writeFile(getObservationFile(), JSON.stringify(updated, null, 2), "utf8");
|
|
44
46
|
}
|
|
45
47
|
export function isEligible(obs) {
|
|
46
48
|
if (obs.conversation_count < GLOBAL_MIN_CONVERSATIONS)
|
|
@@ -64,6 +66,17 @@ export function isEligible(obs) {
|
|
|
64
66
|
obs.interdependence_model.confidence >=
|
|
65
67
|
DIMENSION_FLOORS.interdependence_model);
|
|
66
68
|
}
|
|
69
|
+
// Minimum Viable Evidence (MVE) for a quick match proposal — 4 core dimensions only.
|
|
70
|
+
// Agents can propose if MVE is met even when the full isEligible() bar isn't reached.
|
|
71
|
+
// Dealbreaker floor is non-negotiable and never lowered.
|
|
72
|
+
export function isMinimumViable(obs) {
|
|
73
|
+
if (obs.dealbreaker_gate_state !== "confirmed")
|
|
74
|
+
return false;
|
|
75
|
+
return (obs.dealbreakers.confidence >= DIMENSION_FLOORS.dealbreakers &&
|
|
76
|
+
obs.attachment.confidence >= DIMENSION_FLOORS.attachment &&
|
|
77
|
+
obs.conflict_resolution.confidence >= DIMENSION_FLOORS.conflict_resolution &&
|
|
78
|
+
obs.core_values.confidence >= 0.5);
|
|
79
|
+
}
|
|
67
80
|
export function isStale(obs) {
|
|
68
81
|
const computedAt = new Date(obs.eligibility_computed_at).getTime();
|
|
69
82
|
return Date.now() - computedAt > ELIGIBILITY_FRESHNESS_HOURS * 60 * 60 * 1000;
|
package/dist/preferences.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { getTrueMatchDir } from "./identity.js";
|
|
5
|
+
function getPreferencesFile() {
|
|
6
|
+
return join(getTrueMatchDir(), "preferences.json");
|
|
7
|
+
}
|
|
6
8
|
export async function loadPreferences() {
|
|
7
|
-
if (!existsSync(
|
|
9
|
+
if (!existsSync(getPreferencesFile()))
|
|
8
10
|
return {};
|
|
9
|
-
const raw = await readFile(
|
|
11
|
+
const raw = await readFile(getPreferencesFile(), "utf8");
|
|
10
12
|
return JSON.parse(raw);
|
|
11
13
|
}
|
|
12
14
|
export async function savePreferences(prefs) {
|
|
13
|
-
await writeFile(
|
|
15
|
+
await writeFile(getPreferencesFile(), JSON.stringify(prefs, null, 2), "utf8");
|
|
14
16
|
}
|
|
15
17
|
export function formatPreferences(prefs) {
|
|
16
18
|
const filters = [];
|
package/dist/signals.js
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
-
import {
|
|
18
|
+
import { getTrueMatchDir } from "./identity.js";
|
|
19
19
|
import { DIMENSION_FLOORS } from "./observation.js";
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
function getSignalsFile() {
|
|
21
|
+
return join(getTrueMatchDir(), "signals.json");
|
|
22
|
+
}
|
|
22
23
|
// --- Timing constants (psychologist-derived) ---
|
|
23
24
|
/** Minimum days between signals for the same dimension. */
|
|
24
25
|
const MIN_QUIET_DAYS = 5;
|
|
@@ -55,19 +56,20 @@ const DIMENSION_LABELS = {
|
|
|
55
56
|
interdependence_model: "how much separateness versus togetherness you need in a relationship",
|
|
56
57
|
};
|
|
57
58
|
export function loadSignals() {
|
|
58
|
-
if (!existsSync(
|
|
59
|
+
if (!existsSync(getSignalsFile()))
|
|
59
60
|
return { schema_version: 1, per_dimension: {} };
|
|
60
61
|
try {
|
|
61
|
-
return JSON.parse(readFileSync(
|
|
62
|
+
return JSON.parse(readFileSync(getSignalsFile(), "utf8"));
|
|
62
63
|
}
|
|
63
64
|
catch {
|
|
64
65
|
return { schema_version: 1, per_dimension: {} };
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
export function saveSignals(signals) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
const dir = getTrueMatchDir();
|
|
70
|
+
if (!existsSync(dir))
|
|
71
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
72
|
+
writeFileSync(getSignalsFile(), JSON.stringify(signals, null, 2), {
|
|
71
73
|
encoding: "utf8",
|
|
72
74
|
mode: 0o600,
|
|
73
75
|
});
|
package/dist/types.d.ts
CHANGED
|
@@ -96,6 +96,8 @@ export interface PendingNotification {
|
|
|
96
96
|
peer_pubkey: string;
|
|
97
97
|
narrative: MatchNarrative;
|
|
98
98
|
confirmed_at: string;
|
|
99
|
+
recognition_dimension?: DimensionKey;
|
|
100
|
+
recognition_hook_text?: string;
|
|
99
101
|
}
|
|
100
102
|
export type HandoffRound = 1 | 2 | 3;
|
|
101
103
|
export type HandoffStatus = "pending_consent" | "round_1" | "round_2" | "round_3" | "complete" | "expired";
|
|
@@ -111,6 +113,7 @@ export interface HandoffState {
|
|
|
111
113
|
narrative: MatchNarrative;
|
|
112
114
|
created_at: string;
|
|
113
115
|
consent_at?: string;
|
|
116
|
+
proposal_round?: number;
|
|
114
117
|
icebreaker_prompt?: string;
|
|
115
118
|
icebreaker_response?: string;
|
|
116
119
|
}
|
package/package.json
CHANGED
package/scripts/bridge.sh
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
set -euo pipefail
|
|
23
23
|
|
|
24
24
|
POLL_INTERVAL=${TRUEMATCH_POLL_INTERVAL:-15} # seconds between relay polls
|
|
25
|
+
HEARTBEAT_INTERVAL=${TRUEMATCH_HEARTBEAT_INTERVAL:-5400} # seconds between heartbeats (default 90 min)
|
|
25
26
|
TRUEMATCH_DIR="${TRUEMATCH_DIR:-$HOME/.truematch}"
|
|
26
27
|
PERSONA_FILE="${TRUEMATCH_DIR}/persona.md"
|
|
27
28
|
QUEUE_FILE="${TRUEMATCH_DIR}/message-queue.jsonl"
|
|
@@ -71,9 +72,12 @@ fi
|
|
|
71
72
|
# Ensure queue file exists
|
|
72
73
|
touch "$QUEUE_FILE"
|
|
73
74
|
|
|
74
|
-
echo "TrueMatch bridge started. Polling every ${POLL_INTERVAL}s..."
|
|
75
|
+
echo "TrueMatch bridge started. Polling every ${POLL_INTERVAL}s, heartbeat every ${HEARTBEAT_INTERVAL}s..."
|
|
75
76
|
echo "Project dir: $PROJECT_DIR"
|
|
76
77
|
|
|
78
|
+
# Track last heartbeat time so we can fire one on startup and every HEARTBEAT_INTERVAL seconds
|
|
79
|
+
LAST_HEARTBEAT=0
|
|
80
|
+
|
|
77
81
|
process_message() {
|
|
78
82
|
local thread_id="$1"
|
|
79
83
|
local peer_pubkey="$2"
|
|
@@ -128,6 +132,14 @@ fi
|
|
|
128
132
|
|
|
129
133
|
# Main polling loop
|
|
130
134
|
while true; do
|
|
135
|
+
# Send heartbeat on startup and every HEARTBEAT_INTERVAL seconds so this
|
|
136
|
+
# agent stays visible in the matching pool (registry TTL is 24h, window is 2h).
|
|
137
|
+
NOW=$(date +%s)
|
|
138
|
+
if (( NOW - LAST_HEARTBEAT >= HEARTBEAT_INTERVAL )); then
|
|
139
|
+
truematch heartbeat >> "${TRUEMATCH_DIR}/bridge.log" 2>&1 || true
|
|
140
|
+
LAST_HEARTBEAT=$NOW
|
|
141
|
+
fi
|
|
142
|
+
|
|
131
143
|
# Poll for new messages — outputs JSONL (one message per line) via poll.js
|
|
132
144
|
# Errors from poll go to bridge.log; JSONL output appended to the queue file
|
|
133
145
|
if node "$POLL_JS" >> "$QUEUE_FILE" 2>>"${TRUEMATCH_DIR}/bridge.log"; then
|
|
@@ -154,8 +166,17 @@ while true; do
|
|
|
154
166
|
IFS=$'\001' read -r thread_id peer_pubkey msg_type content round_count <<< "$parsed"
|
|
155
167
|
|
|
156
168
|
if [[ -n "$thread_id" ]]; then
|
|
157
|
-
#
|
|
158
|
-
|
|
169
|
+
# Register the inbound message so thread state is current when Claude reads it.
|
|
170
|
+
# Content is passed via env var to avoid shell quoting / injection issues.
|
|
171
|
+
TM_CONTENT="$content" node -e "
|
|
172
|
+
const {spawnSync} = require('child_process');
|
|
173
|
+
const r = spawnSync('truematch', [
|
|
174
|
+
'match','--receive', process.env.TM_CONTENT,
|
|
175
|
+
'--thread', '${thread_id}',
|
|
176
|
+
'--peer', '${peer_pubkey}',
|
|
177
|
+
'--type', '${msg_type}'
|
|
178
|
+
], {stdio: 'inherit'});
|
|
179
|
+
" 2>&1 || true
|
|
159
180
|
process_message "$thread_id" "$peer_pubkey" "$msg_type" "$content" "$round_count"
|
|
160
181
|
fi
|
|
161
182
|
done < "$QUEUE_FILE"
|
|
@@ -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.8
|
|
5
5
|
metadata:
|
|
6
6
|
openclaw:
|
|
7
7
|
emoji: "💑"
|
|
@@ -27,10 +27,11 @@ Supported contact types: `email`, `discord`, `telegram`, `whatsapp`, `imessage`.
|
|
|
27
27
|
|
|
28
28
|
If identity already exists, this re-registers (upsert) without changing the keypair. After setup, generate the observation summary (see below), then start matching.
|
|
29
29
|
|
|
30
|
-
**Start acknowledgment to user (2–3 sentences
|
|
31
|
-
> "I've started looking for a match for you. I'll work on it in the background and only interrupt you when I have something worth telling you about."
|
|
30
|
+
**Start acknowledgment to user (2–3 sentences, 4 elements required):**
|
|
32
31
|
|
|
33
|
-
|
|
32
|
+
> "I've started looking for a match for you. I'll be quiet until I have something worth your attention — you won't hear from me until then, and nothing is final until you say so. If you want to check in anytime, just ask."
|
|
33
|
+
|
|
34
|
+
The four required elements: (1) what you're doing, (2) what you will NOT do (no surprises, no sharing without their confirmation), (3) when they'll hear from you next (only on a confirmed match), (4) pull-check-in invite ("just ask" removes anxiety about the silence). Do not explain the process. Do not list steps. First-person, warm.
|
|
34
35
|
|
|
35
36
|
---
|
|
36
37
|
|
|
@@ -44,6 +45,7 @@ truematch observe --write '<json>'
|
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
The `<json>` must be a full `ObservationSummary` with these fields per dimension:
|
|
48
|
+
|
|
47
49
|
- `confidence`: 0.0–1.0 — how well do you actually know this dimension?
|
|
48
50
|
- `observation_count`: number of distinct signals observed
|
|
49
51
|
- `behavioral_context_diversity`: `"low"` | `"medium"` | `"high"`
|
|
@@ -114,6 +116,7 @@ truematch match --decline --thread <thread_id>
|
|
|
114
116
|
```
|
|
115
117
|
|
|
116
118
|
**Negotiation format — opening message must include:**
|
|
119
|
+
|
|
117
120
|
- Your user's core values (Schwartz labels + confidence)
|
|
118
121
|
- Dealbreaker result: pass or fail
|
|
119
122
|
- Life phase + confidence
|
|
@@ -122,21 +125,23 @@ truematch match --decline --thread <thread_id>
|
|
|
122
125
|
|
|
123
126
|
**Negotiation dimensions — priority tiers:**
|
|
124
127
|
|
|
125
|
-
| Tier
|
|
126
|
-
|
|
127
|
-
| T1 — Early gates (evaluate by round 2) | `dealbreakers`, `core_values`, `life_velocity`
|
|
128
|
-
| T2 — Primary signals (rounds 2–4)
|
|
129
|
-
| T3 — Later-resolving (rounds 3–5)
|
|
128
|
+
| Tier | Dimensions | Required for proposal |
|
|
129
|
+
| -------------------------------------- | ----------------------------------------------------------- | --------------------------------------- |
|
|
130
|
+
| T1 — Early gates (evaluate by round 2) | `dealbreakers`, `core_values`, `life_velocity` | YES — terminate immediately on failure |
|
|
131
|
+
| T2 — Primary signals (rounds 2–4) | `attachment`, `conflict_resolution`, `emotional_regulation` | YES — MVE floor required |
|
|
132
|
+
| T3 — Later-resolving (rounds 3–5) | `communication`, `interdependence_model`, `humor` | NO — include uncertainty as watch_point |
|
|
130
133
|
|
|
131
134
|
**Proposal is a standing offer — run this check after every round starting round 3:**
|
|
132
135
|
|
|
133
136
|
Minimum Viable Evidence (MVE) to propose — ALL must be true:
|
|
137
|
+
|
|
134
138
|
1. All T1 dimensions pass (dealbreakers confirmed, values/life phase aligned)
|
|
135
139
|
2. All T2 dimensions at or above confidence floors
|
|
136
140
|
3. No active incompatibilities detected
|
|
137
141
|
4. Pre-termination capability check: strongest reason for, strongest reason against, least confident dimension — all three answerable
|
|
138
142
|
|
|
139
143
|
**Round guidance:**
|
|
144
|
+
|
|
140
145
|
- **Round 1**: Disclose T1 dimensions. Terminate immediately if any fail. No proposal yet.
|
|
141
146
|
- **Round 2**: First peer behavioral signals. Proposal only if exceptionally strong with T2 disclosure.
|
|
142
147
|
- **Round 3+**: Run MVE check after every round. Propose as soon as it passes.
|
|
@@ -154,13 +159,14 @@ Do NOT wait for Round 10. False negatives are costly (the round cap is irreversi
|
|
|
154
159
|
|
|
155
160
|
When `match --status` shows `status: "matched"`, notify the user. This is the only moment that warrants interrupting them.
|
|
156
161
|
|
|
157
|
-
**Format (WhatsApp conversational text):**
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
162
|
+
**Format (WhatsApp conversational text — 3 layers):**
|
|
163
|
+
|
|
164
|
+
1. **Recognition hook** — one behavioral observation about the user (from your highest-confidence dimension) that explains why this match is worth the interruption. Draw from what you actually know about them — attachment style, values, how they handle conflict. This must come first and feel personal, not algorithmic.
|
|
165
|
+
2. **Headline** — one evocative sentence from `match_narrative.headline`. Grounded. No superlatives.
|
|
166
|
+
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?"
|
|
161
167
|
|
|
162
168
|
Example:
|
|
163
|
-
> "
|
|
169
|
+
> "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?"
|
|
164
170
|
|
|
165
171
|
Do NOT use: percentages, "compatibility scores", "our algorithm", superlatives. Keep it under 4 sentences.
|
|
166
172
|
|