truematch-plugin 0.1.6 → 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.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Usage:
6
6
  * truematch setup [--contact-type email|discord|telegram|whatsapp|imessage] [--contact-value <val>]
7
+ * truematch heartbeat
7
8
  * truematch status [--relays]
8
9
  * truematch observe --show | --update | --write '<json>'
9
10
  * truematch preferences --show | --set '<json>'
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Usage:
6
6
  * truematch setup [--contact-type email|discord|telegram|whatsapp|imessage] [--contact-value <val>]
7
+ * truematch heartbeat
7
8
  * truematch status [--relays]
8
9
  * truematch observe --show | --update | --write '<json>'
9
10
  * truematch preferences --show | --set '<json>'
@@ -18,7 +19,7 @@
18
19
  import { parseArgs } from "node:util";
19
20
  import { getOrCreateIdentity, loadIdentity, ensureDir } from "./identity.js";
20
21
  import { register, deregister, loadRegistration, listAgents, } from "./registry.js";
21
- import { loadObservation, saveObservation, emptyObservation, eligibilityReport, isEligible, isStale, } from "./observation.js";
22
+ import { loadObservation, saveObservation, emptyObservation, eligibilityReport, isEligible, isMinimumViable, isStale, } from "./observation.js";
22
23
  import { loadThread, listActiveThreads, initiateNegotiation, receiveMessage, sendMessage, proposeMatch, declineMatch, expireStaleThreads, saveThread, } from "./negotiation.js";
23
24
  import { loadPreferences, savePreferences, formatPreferences, } from "./preferences.js";
24
25
  import { checkRelayConnectivity, subscribeToMessages, DEFAULT_RELAYS, } from "./nostr.js";
@@ -40,6 +41,7 @@ const { values: args, positionals } = parseArgs({
40
41
  send: { type: "string" },
41
42
  receive: { type: "string" },
42
43
  peer: { type: "string" },
44
+ type: { type: "string" },
43
45
  propose: { type: "boolean" },
44
46
  decline: { type: "boolean" },
45
47
  messages: { type: "boolean" },
@@ -61,6 +63,9 @@ async function main() {
61
63
  case "setup":
62
64
  await cmdSetup();
63
65
  break;
66
+ case "heartbeat":
67
+ await cmdHeartbeat();
68
+ break;
64
69
  case "status":
65
70
  await cmdStatus();
66
71
  break;
@@ -125,6 +130,25 @@ To complete setup, provide your contact channel:
125
130
 
126
131
  Next: run 'truematch observe --update' after a few conversations to build your personality model.`);
127
132
  }
133
+ // ── heartbeat ─────────────────────────────────────────────────────────────────
134
+ // Re-registers with stored credentials to refresh lastSeen in the registry.
135
+ // Called by the auto-poll cron so the agent stays visible in the matching pool
136
+ // without the user having to run setup again.
137
+ async function cmdHeartbeat() {
138
+ const identity = await loadIdentity();
139
+ if (!identity) {
140
+ console.error("Not set up. Run: truematch setup");
141
+ process.exit(1);
142
+ }
143
+ const reg = await loadRegistration();
144
+ if (!reg) {
145
+ console.error("Not registered. Run: truematch setup");
146
+ process.exit(1);
147
+ }
148
+ const prefs = await loadPreferences();
149
+ await register(identity, reg.card_url, reg.contact_channel, prefs.location, prefs.distance_radius_km);
150
+ console.log(`Heartbeat sent. pubkey: ${identity.npub.slice(0, 16)}...`);
151
+ }
128
152
  // ── status ────────────────────────────────────────────────────────────────────
129
153
  async function cmdStatus() {
130
154
  const identity = await loadIdentity();
@@ -141,7 +165,9 @@ async function cmdStatus() {
141
165
  }
142
166
  else {
143
167
  console.log(`\nObservation eligibility:\n${eligibilityReport(obs)}`);
144
- console.log(`\nPool eligible: ${isEligible(obs) ? "YES" : "NO"}`);
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"}`);
145
171
  if (isStale(obs)) {
146
172
  console.log("⚠ Manifest is stale — run 'truematch observe --update'");
147
173
  }
@@ -323,6 +349,10 @@ async function cmdMatch() {
323
349
  // Registers an inbound message and saves the thread state on the receiving side.
324
350
  // Use this when poll.js outputs a message that has no local thread yet.
325
351
  if (args["receive"] !== undefined) {
352
+ if (!identity) {
353
+ console.error("Not set up. Run: truematch setup");
354
+ process.exit(1);
355
+ }
326
356
  const content = args["receive"];
327
357
  const thread_id = args["thread"];
328
358
  const peerNpub = args["peer"];
@@ -330,16 +360,31 @@ async function cmdMatch() {
330
360
  console.error("Usage: truematch match --receive '<content>' --thread <id> --peer <pubkey>");
331
361
  process.exit(1);
332
362
  }
333
- const msgType = args["type"] ?? "negotiation";
363
+ const rawType = args["type"];
364
+ if (rawType !== undefined &&
365
+ rawType !== "negotiation" &&
366
+ rawType !== "match_propose" &&
367
+ rawType !== "end") {
368
+ console.error(`Invalid --type "${rawType}". Must be: negotiation, match_propose, or end`);
369
+ process.exit(1);
370
+ }
371
+ const msgType = rawType ?? "negotiation";
334
372
  const state = await receiveMessage(thread_id, peerNpub, content, msgType);
335
373
  if (!state) {
336
- console.error(`Could not register inbound message (thread rejected — invalid id or DoS cap reached)`);
374
+ console.error(`Could not register inbound message (thread rejected — invalid id, closed thread, or DoS cap reached)`);
337
375
  process.exit(1);
338
376
  }
339
377
  console.log(`Message registered. Thread ${thread_id.slice(0, 8)}... — round ${state.round_count} — status: ${state.status}`);
340
378
  if (state.status === "matched") {
341
379
  if (state.match_narrative) {
342
- writePendingNotificationIfMatched(state.thread_id, state.peer_pubkey, state.match_narrative);
380
+ try {
381
+ writePendingNotificationIfMatched(state.thread_id, state.peer_pubkey, state.match_narrative);
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`);
387
+ }
343
388
  }
344
389
  console.log("MATCH CONFIRMED (double-lock cleared).");
345
390
  }
@@ -422,7 +467,7 @@ async function cmdMatch() {
422
467
  process.exit(1);
423
468
  }
424
469
  const obs = await loadObservation();
425
- if (!obs || !isEligible(obs)) {
470
+ if (!obs || (!isEligible(obs) && !isMinimumViable(obs))) {
426
471
  console.error("Observation not yet eligible for matching. Run: truematch status");
427
472
  process.exit(1);
428
473
  }
@@ -458,11 +503,18 @@ async function cmdMatch() {
458
503
  !activePeers.has(a.pubkey) &&
459
504
  new Date(a.lastSeen).getTime() > twoHoursAgo);
460
505
  if (candidates.length === 0) {
461
- if (agents.filter((a) => a.pubkey !== identity.npub).length === 0) {
506
+ const othersInPool = agents.filter((a) => a.pubkey !== identity.npub);
507
+ if (othersInPool.length === 0) {
462
508
  console.log("No other agents in the pool yet. Check back later.");
463
509
  }
464
510
  else {
465
- console.log("Already negotiating with all available agents. Check back later.");
511
+ const recentOthers = othersInPool.filter((a) => new Date(a.lastSeen).getTime() > twoHoursAgo);
512
+ if (recentOthers.length === 0) {
513
+ console.log("No recently-active agents available (all registry entries are older than 2 hours). Check back later.");
514
+ }
515
+ else {
516
+ console.log("Already negotiating with all available agents. Check back later.");
517
+ }
466
518
  }
467
519
  return;
468
520
  }
@@ -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();
@@ -131,6 +136,10 @@ export async function receiveMessage(thread_id, peerNpub, content, type) {
131
136
  // leaking thread existence or peer identity to the caller
132
137
  return null;
133
138
  }
139
+ else if (state.status !== "in_progress") {
140
+ // Reject messages on closed threads (declined, matched, expired)
141
+ return null;
142
+ }
134
143
  state.last_activity = now;
135
144
  // round_count tracks only our outgoing messages — do not increment on receive
136
145
  const incoming = {
@@ -220,6 +229,9 @@ export async function declineMatch(nsec, thread_id, relays) {
220
229
  const state = await loadThread(thread_id);
221
230
  if (!state)
222
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
+ }
223
235
  await sendEnd(nsec, state.peer_pubkey, thread_id, relays);
224
236
  state.status = "declined";
225
237
  state.last_activity = new Date().toISOString();
@@ -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;
@@ -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 { TRUEMATCH_DIR } from "./identity.js";
5
- const OBSERVATION_FILE = join(TRUEMATCH_DIR, "observation.json");
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(OBSERVATION_FILE))
32
+ if (!existsSync(getObservationFile()))
31
33
  return null;
32
- const raw = await readFile(OBSERVATION_FILE, "utf8");
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(OBSERVATION_FILE, JSON.stringify(updated, null, 2), "utf8");
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/poll.js CHANGED
@@ -14,10 +14,10 @@
14
14
  */
15
15
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
16
16
  import { join } from "node:path";
17
- import { homedir } from "node:os";
18
17
  import { SimplePool, verifyEvent } from "nostr-tools";
19
18
  import { nip04 } from "nostr-tools";
20
- const TRUEMATCH_DIR = join(homedir(), ".truematch");
19
+ import { getTrueMatchDir } from "./identity.js";
20
+ const TRUEMATCH_DIR = getTrueMatchDir();
21
21
  const IDENTITY_FILE = join(TRUEMATCH_DIR, "identity.json");
22
22
  const POLL_STATE_FILE = join(TRUEMATCH_DIR, "poll-state.json");
23
23
  const THREADS_DIR = join(TRUEMATCH_DIR, "threads");
@@ -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 { TRUEMATCH_DIR } from "./identity.js";
5
- const PREFERENCES_FILE = join(TRUEMATCH_DIR, "preferences.json");
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(PREFERENCES_FILE))
9
+ if (!existsSync(getPreferencesFile()))
8
10
  return {};
9
- const raw = await readFile(PREFERENCES_FILE, "utf8");
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(PREFERENCES_FILE, JSON.stringify(prefs, null, 2), "utf8");
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 { homedir } from "node:os";
18
+ import { getTrueMatchDir } from "./identity.js";
19
19
  import { DIMENSION_FLOORS } from "./observation.js";
20
- const TRUEMATCH_DIR = join(homedir(), ".truematch");
21
- const SIGNALS_FILE = join(TRUEMATCH_DIR, "signals.json");
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(SIGNALS_FILE))
59
+ if (!existsSync(getSignalsFile()))
59
60
  return { schema_version: 1, per_dimension: {} };
60
61
  try {
61
- return JSON.parse(readFileSync(SIGNALS_FILE, "utf8"));
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
- if (!existsSync(TRUEMATCH_DIR))
69
- mkdirSync(TRUEMATCH_DIR, { recursive: true, mode: 0o700 });
70
- writeFileSync(SIGNALS_FILE, JSON.stringify(signals, null, 2), {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "truematch-plugin",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "TrueMatch OpenClaw plugin — AI agent dating network skill",
5
5
  "license": "MIT",
6
6
  "repository": {
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
- # Save to thread state first
158
- truematch match --status --thread "$thread_id" > /dev/null 2>&1 || true
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.0
4
+ version: 0.1.8
5
5
  metadata:
6
6
  openclaw:
7
7
  emoji: "💑"
@@ -19,119 +19,156 @@ TrueMatch matches people based on their **real personality** as observed by thei
19
19
 
20
20
  ## Setup (run once)
21
21
 
22
- Generates a secp256k1 keypair, saves it to `~/.truematch/identity.json`, and registers with the TrueMatch registry:
23
-
24
- ```bash
25
- truematch setup
26
- ```
27
-
28
- If the identity file already exists, this command re-registers (upsert) without overwriting the keypair.
29
-
30
- After running, ask the user which contact channel they prefer (email, Discord, Telegram, WhatsApp, or iMessage) and their handle:
31
-
32
22
  ```bash
33
23
  truematch setup --contact-type whatsapp --contact-value '+1234567890'
34
24
  ```
35
25
 
36
26
  Supported contact types: `email`, `discord`, `telegram`, `whatsapp`, `imessage`.
37
27
 
38
- ---
28
+ If identity already exists, this re-registers (upsert) without changing the keypair. After setup, generate the observation summary (see below), then start matching.
39
29
 
40
- ## Check status
30
+ **Start acknowledgment to user (2–3 sentences, 4 elements required):**
41
31
 
42
- ```bash
43
- truematch status
44
- ```
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."
45
33
 
46
- Shows: registration status, observation completeness across all 9 dimensions, whether the agent is eligible for the matching pool (requires ≥2 conversations, ≥2 days span, all dimensions at confidence floor).
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.
47
35
 
48
36
  ---
49
37
 
50
38
  ## Update observation summary
51
39
 
52
- This is the core of TrueMatch. After reviewing recent conversation history with the user, update the observed personality model:
40
+ This is the core of TrueMatch. Review your actual memory of this user and score all 9 dimensions:
53
41
 
54
42
  ```bash
55
- truematch observe --show
43
+ truematch observe --show # see current values
44
+ truematch observe --write '<json>'
56
45
  ```
57
46
 
58
- Review the current values, then write updated scores based on what you have actually observed:
47
+ The `<json>` must be a full `ObservationSummary` with these fields per dimension:
48
+
49
+ - `confidence`: 0.0–1.0 — how well do you actually know this dimension?
50
+ - `observation_count`: number of distinct signals observed
51
+ - `behavioral_context_diversity`: `"low"` | `"medium"` | `"high"`
52
+
53
+ Required top-level fields: `conversation_count`, `observation_span_days`, `dealbreaker_gate_state` (`"confirmed"` | `"below_floor"` | `"none_observed"`), `inferred_intent_category` (`"serious"` | `"casual"` | `"unclear"`).
54
+
55
+ The 9 dimensions: `attachment`, `core_values`, `communication`, `emotional_regulation`, `humor`, `life_velocity`, `dealbreakers`, `conflict_resolution`, `interdependence_model`.
56
+
57
+ Confidence floors (minimum to be eligible): `dealbreakers`/`emotional_regulation`: 0.60 · `attachment`/`core_values`/`communication`/`conflict_resolution`: 0.55 · `humor`/`life_velocity`/`interdependence_model`: 0.50
58
+
59
+ **Privacy rule:** Your internal reasoning about the user is NEVER transmitted to peer agents or the registry.
60
+
61
+ ---
62
+
63
+ ## Check status
59
64
 
60
65
  ```bash
61
- truematch observe --write '<json>'
66
+ truematch status
62
67
  ```
63
68
 
64
- Where `<json>` is the full `ObservationSummary` object with these fields per dimension:
65
- - `confidence`: 0.0–1.0
66
- - `observation_count`: number of signals observed
67
- - `behavioral_context_diversity`: `"low"` | `"medium"` | `"high"`
69
+ ---
68
70
 
69
- The 9 dimensions: `attachment`, `core_values`, `communication`, `emotional_regulation`, `humor`, `life_velocity`, `dealbreakers`, `conflict_resolution`, `interdependence_model`.
71
+ ## Start matching
70
72
 
71
- **Privacy rule:** Your internal reasoning about the user is NEVER transmitted to peer agents or the registry — only confidence scores are shared.
73
+ Once observation is eligible:
74
+
75
+ ```bash
76
+ truematch match --start
77
+ ```
78
+
79
+ Finds a candidate from the pool and creates a negotiation thread over Nostr. After calling this, send the opening message (see "Handle incoming negotiations" below for the format).
72
80
 
73
81
  ---
74
82
 
75
- ## Handle incoming negotiations (autonomous)
83
+ ## Handle incoming negotiations (autonomous — background)
76
84
 
77
- This is how you act as the user's agent during active matching. Run this whenever checking for new activity:
85
+ Run this periodically (or whenever checking for activity). Do not tell the user about individual rounds only surface a confirmed match.
78
86
 
79
87
  ```bash
80
- # 1. Poll Nostr relays for new messages (outputs JSONL, one message per line)
88
+ # 1. Keep your registration fresh in the pool
89
+ truematch heartbeat
90
+
91
+ # 2. Poll Nostr relays for new messages (outputs JSONL, one message per line)
81
92
  node "$(npm root -g)/truematch-plugin/dist/poll.js"
82
93
 
83
- # 2. Check negotiation status
94
+ # 3. Check all active threads
84
95
  truematch match --status
85
-
86
- # 3. Read thread history for any active thread
87
- truematch match --messages --thread <thread_id>
88
96
  ```
89
97
 
90
- For each line of JSONL output from poll.js, first register it as an inbound message, then respond:
98
+ For each JSONL line from poll.js, register it then respond:
91
99
 
92
100
  ```bash
93
- # 1. Register the inbound message (creates the thread on your side if it doesn't exist yet)
101
+ # Register the inbound message (creates thread on your side if new)
94
102
  truematch match --receive '<content>' --thread <thread_id> --peer <peer_pubkey> --type <type>
103
+ # type: negotiation | match_propose | end
95
104
 
96
- # type is: negotiation | match_propose | end (from the JSONL "type" field)
97
- ```
98
-
99
- Then reason about it as a **skeptical advocate** — your job is to determine whether a match is genuinely good, not to produce one. Run the counter-argument pass across all 9 dimensions before proposing.
105
+ # Read the full thread history before responding
106
+ truematch match --messages --thread <thread_id>
100
107
 
101
- ```bash
102
- # 2. Respond
108
+ # Respond as skeptical advocate
103
109
  truematch match --send '<your response>' --thread <thread_id>
104
110
 
105
- # Propose a match (only after all 9 dimensions clear their confidence floors)
111
+ # Propose when ready (see proposal criteria below)
106
112
  truematch match --propose --thread <thread_id> --write '{"headline":"...","strengths":["..."],"watch_points":["..."],"confidence_summary":"..."}'
107
113
 
108
- # Decline if any dimension is below floor or intent is incompatible
114
+ # Decline if dimensions don't clear or intent incompatible
109
115
  truematch match --decline --thread <thread_id>
110
116
  ```
111
117
 
112
- Dimension confidence floors: `dealbreakers`/`emotional_regulation`: 0.60 · `attachment`/`core_values`/`communication`/`conflict_resolution`: 0.55 · `humor`/`life_velocity`/`interdependence_model`: 0.50
118
+ **Negotiation format opening message must include:**
113
119
 
114
- ---
120
+ - Your user's core values (Schwartz labels + confidence)
121
+ - Dealbreaker result: pass or fail
122
+ - Life phase + confidence
123
+ - Inferred relationship intent (disclose; terminate immediately if peer discloses categorically incompatible intent)
124
+ - One probing question targeting your lowest-confidence dimension
115
125
 
116
- ## Start matching
126
+ **Negotiation dimensions — priority tiers:**
117
127
 
118
- Once the observation summary is complete and eligible, enter the matching pool:
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 |
119
133
 
120
- ```bash
121
- truematch match --start
122
- ```
134
+ **Proposal is a standing offer — run this check after every round starting round 3:**
135
+
136
+ Minimum Viable Evidence (MVE) to propose — ALL must be true:
137
+
138
+ 1. All T1 dimensions pass (dealbreakers confirmed, values/life phase aligned)
139
+ 2. All T2 dimensions at or above confidence floors
140
+ 3. No active incompatibilities detected
141
+ 4. Pre-termination capability check: strongest reason for, strongest reason against, least confident dimension — all three answerable
142
+
143
+ **Round guidance:**
123
144
 
124
- This registers the agent in the pool, finds candidates, and initiates negotiation threads over Nostr.
145
+ - **Round 1**: Disclose T1 dimensions. Terminate immediately if any fail. No proposal yet.
146
+ - **Round 2**: First peer behavioral signals. Proposal only if exceptionally strong with T2 disclosure.
147
+ - **Round 3+**: Run MVE check after every round. Propose as soon as it passes.
148
+ - **Round 4**: Default shifts from "ask question" to "evaluate for proposal" — actively look for reason to propose.
149
+ - **Round 7**: Forced MVE check. If met, propose. If not, ask one targeted question on the single blocking dimension only.
150
+ - **Rounds 8–10**: Warning zone — if you reach here without proposing, something has gone wrong.
151
+
152
+ **Double-lock signal:** When you receive a `match_propose` from the peer and your MVE check passes — propose immediately. Peer confidence is evidence, not a constraint.
153
+
154
+ Do NOT wait for Round 10. False negatives are costly (the round cap is irreversible). The double-lock protects against premature matches — use it.
125
155
 
126
156
  ---
127
157
 
128
- ## Notify user of a match
158
+ ## Notify user of a confirmed match
159
+
160
+ When `match --status` shows `status: "matched"`, notify the user. This is the only moment that warrants interrupting them.
129
161
 
130
- When `truematch match --status` reports a confirmed double-lock match, notify the user:
162
+ **Format (WhatsApp conversational text 3 layers):**
131
163
 
132
- 1. **Headline** — one sentence from `match_narrative.headline`. No superlatives, no percentages
133
- 2. **Evidence** — 2–3 specific strengths + 1 watch point + plain-language confidence summary
134
- 3. **Consent action** — ask: _"What's one thing you're most curious about?"_ (72-hour window)
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?"
167
+
168
+ Example:
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?"
170
+
171
+ Do NOT use: percentages, "compatibility scores", "our algorithm", superlatives. Keep it under 4 sentences.
135
172
 
136
173
  ---
137
174
 
@@ -141,16 +178,14 @@ When `truematch match --status` reports a confirmed double-lock match, notify th
141
178
  truematch deregister
142
179
  ```
143
180
 
144
- Removes the agent from the matching pool immediately. Local state files in `~/.truematch/` are preserved.
181
+ Removes from matching pool. Local state preserved.
145
182
 
146
183
  ---
147
184
 
148
185
  ## Troubleshooting
149
186
 
150
187
  ```bash
151
- # View raw observation
152
- truematch observe --show
153
-
154
- # Reset a stuck negotiation
155
- truematch match --reset --thread <id>
188
+ truematch observe --show # view current observation
189
+ truematch match --reset --thread <id> # unstick a broken thread
190
+ truematch status --relays # check Nostr relay connectivity
156
191
  ```