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 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
- 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"}`);
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
- // Notification write failed — match is still confirmed
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
  }
@@ -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();
@@ -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;
@@ -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.7",
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.7
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 max):**
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
- Do not explain the process. Do not list steps. First-person, warm.
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 | Dimensions | Required for proposal |
126
- |------|-----------|----------------------|
127
- | T1 — Early gates (evaluate by round 2) | `dealbreakers`, `core_values`, `life_velocity` | YES — terminate immediately on failure |
128
- | T2 — Primary signals (rounds 2–4) | `attachment`, `conflict_resolution`, `emotional_regulation` | YES — MVE floor required |
129
- | T3 — Later-resolving (rounds 3–5) | `communication`, `interdependence_model`, `humor` | NO — include uncertainty as watch_point |
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
- 1. Reference something specific you know about the user as the reason for the interruption — not algorithm language
159
- 2. One evocative sentence about the match from `match_narrative.headline`
160
- 3. Single call-to-action: _"Want to see more?"_
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
- > "Given how you actually workthe build intensity, the independence model — I thought this was worth interrupting you for. [headline]. Want to see more?"
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