truematch-plugin 0.1.9 → 0.1.11

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 CHANGED
@@ -61,17 +61,17 @@ plugin/
61
61
 
62
62
  ## 9-Dimension Observation Model
63
63
 
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 |
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 ──────────────────────────────────────────────────────
@@ -38,7 +41,7 @@ export function loadPendingNotification() {
38
41
  export function savePendingNotification(n) {
39
42
  const dir = getTrueMatchDir();
40
43
  if (!existsSync(dir))
41
- mkdirSync(dir, { recursive: true });
44
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
42
45
  writeFileSync(getNotificationFile(), JSON.stringify(n, null, 2), {
43
46
  encoding: "utf8",
44
47
  mode: 0o600,
@@ -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))
@@ -141,10 +146,15 @@ export function buildMatchNotificationContext(n) {
141
146
  `each acting for their own user, reached the same conclusion. That convergence is the`,
142
147
  `signal. It is not algorithmic — it is two independent judgements that happened to agree.`,
143
148
  ``,
149
+ `**Surface the bilateral convergence to the user in plain language — this is the emotional core.**`,
150
+ `Not as a technical explanation, but as what it actually means: two independent AI advocates,`,
151
+ `each loyal to their own person, thought of the same pairing at the same time. That is rare.`,
152
+ `The user should understand this mechanism. It is what makes this different from an algorithm.`,
153
+ ``,
144
154
  `What makes this compelling:`,
145
155
  strengths,
146
156
  ``,
147
- `What to be honest about:`,
157
+ `Where to be genuinely honest (without using the word "watch points" — that is internal language):`,
148
158
  watchPoints,
149
159
  ``,
150
160
  `Confidence: ${narrative.confidence_summary}`,
@@ -155,19 +165,11 @@ export function buildMatchNotificationContext(n) {
155
165
  ``,
156
166
  `Instead: anchor to something specific you have actually noticed about this person.`,
157
167
  `Name a real pattern, a real moment, a real thing that keeps staying with you.`,
158
- `Then — after a beat say you want to tell them about someone.`,
159
- ``,
160
- `The structure is: specific true observation → brief pause → "I want to tell you about someone."`,
161
- ``,
162
- `**Pacing — this should feel like a conversation, not a notification:**`,
163
- `- Do not rush to name the match. Let anticipation build naturally.`,
164
- `- Share the compelling elements first. Then the watch points.`,
165
- `- After the watch points, give them a moment to react before asking anything.`,
166
- `- When they've responded to the full picture, ask: "What's one thing you're most curious about?"`,
167
- ` That question is how they say yes. Their answer (however they answer) is consent.`,
168
+ `Then — after thattell them about the bilateral convergence, and then about this person.`,
168
169
  ``,
169
- `The 3-round handoff should complete within 48–72 hours this is not a slow process.`,
170
- `Round 1 is this conversation. Keep the energy alive.`,
170
+ `Deliver this as a single, compact message — not a multi-turn debrief. Three elements:`,
171
+ `(1) the specific observation about them, (2) the bilateral convergence in plain language,`,
172
+ `(3) "What's one thing you'd want to know about them?" Their answer is consent.`,
171
173
  ``,
172
174
  `After they respond to the curiosity question, record it:`,
173
175
  ` truematch handoff --round 1 --match-id ${n.match_id} --consent "<their response>"`,
@@ -278,6 +280,8 @@ export function advanceHandoff(matchId, round, options) {
278
280
  if (round === 1) {
279
281
  if (!options.consent)
280
282
  return `Round 1 requires --consent "<user response>"`;
283
+ if (state.status !== "pending_consent")
284
+ return `Cannot advance to Round 1: current status is "${state.status}" (expected "pending_consent").`;
281
285
  const updated = {
282
286
  ...state,
283
287
  status: "round_1",
@@ -292,6 +296,8 @@ export function advanceHandoff(matchId, round, options) {
292
296
  return `Handoff ${matchId} — user opted out. Match quietly re-enters the pool.`;
293
297
  }
294
298
  if (options.prompt) {
299
+ if (state.status !== "round_1")
300
+ return `Cannot set icebreaker prompt: current status is "${state.status}" (expected "round_1").`;
295
301
  saveHandoffState({
296
302
  ...state,
297
303
  status: "round_2",
@@ -300,6 +306,8 @@ export function advanceHandoff(matchId, round, options) {
300
306
  return `Icebreaker prompt recorded. Share it with the user.`;
301
307
  }
302
308
  if (options.response) {
309
+ if (state.status !== "round_2")
310
+ return `Cannot record icebreaker response: current status is "${state.status}" (expected "round_2").`;
303
311
  saveHandoffState({
304
312
  ...state,
305
313
  icebreaker_response: options.response,
@@ -312,6 +320,8 @@ export function advanceHandoff(matchId, round, options) {
312
320
  if (round === 3) {
313
321
  if (!options.exchange)
314
322
  return `Round 3 requires --exchange to confirm contact exchange.`;
323
+ if (state.status !== "round_3")
324
+ return `Cannot complete handoff: current status is "${state.status}" (expected "round_3").`;
315
325
  saveHandoffState({ ...state, status: "complete" });
316
326
  return `Handoff complete. Platform has withdrawn. Contact exchange confirmed.`;
317
327
  }
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
- writePendingNotificationIfMatched(updated.thread_id, updated.peer_pubkey, updated.match_narrative);
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)");
@@ -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
@@ -78,7 +78,12 @@ export async function expireStaleThreads(nsec, relays) {
78
78
  thread.status = "expired";
79
79
  thread.last_activity = new Date().toISOString();
80
80
  await saveThread(thread);
81
- await sendEnd(nsec, thread.peer_pubkey, thread.thread_id, relays);
81
+ try {
82
+ await sendEnd(nsec, thread.peer_pubkey, thread.thread_id, relays);
83
+ }
84
+ catch {
85
+ // Relay unavailable — thread is already marked expired locally; peer will time out naturally
86
+ }
82
87
  }
83
88
  }
84
89
  }
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
- return nip04.encrypt(senderNsec, recipientNpub, plaintext);
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
- const plaintext = nip04.decrypt(recipientNsec, senderNpub, ciphertext);
22
- return JSON.parse(plaintext);
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
- try {
74
- const message = decryptMessage(recipientNsec, senderNpub, event.content);
75
- // Only process TrueMatch protocol messages
76
- if (typeof message === "object" &&
77
- message !== null &&
78
- "truematch" in message &&
79
- message.truematch === "2.0") {
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: () => {
@@ -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 isPoolEligible(obs: ObservationSummary): boolean;
17
18
  export declare function isMinimumViable(obs: ObservationSummary): boolean;
18
19
  export declare function isStale(obs: ObservationSummary): boolean;
19
20
  export declare function emptyObservation(): ObservationSummary;
@@ -45,7 +45,7 @@ export async function saveObservation(obs) {
45
45
  ...obs,
46
46
  updated_at: now,
47
47
  eligibility_computed_at: now,
48
- matching_eligible: isEligible(obs),
48
+ matching_eligible: isPoolEligible(obs),
49
49
  };
50
50
  const dir = getTrueMatchDir();
51
51
  if (!existsSync(dir))
@@ -55,6 +55,7 @@ export async function saveObservation(obs) {
55
55
  mode: 0o600,
56
56
  });
57
57
  }
58
+ // isEligible: full 9-dimension check — used for reporting and manual inspection.
58
59
  export function isEligible(obs) {
59
60
  if (obs.conversation_count < GLOBAL_MIN_CONVERSATIONS)
60
61
  return false;
@@ -77,16 +78,41 @@ export function isEligible(obs) {
77
78
  obs.interdependence_model.confidence >=
78
79
  DIMENSION_FLOORS.interdependence_model);
79
80
  }
80
- // Minimum Viable Evidence (MVE) for a quick match proposal — 4 core dimensions only.
81
- // Agents can propose if MVE is met even when the full isEligible() bar isn't reached.
81
+ // isPoolEligible: gates entry into the matching pool.
82
+ // Requires T1 (dealbreakers, core_values, life_velocity) and T2 (attachment,
83
+ // conflict_resolution, emotional_regulation) dimensions only — T3 dimensions
84
+ // (humor, communication, interdependence_model) resolve later in negotiation
85
+ // and must NOT block pool entry.
86
+ export function isPoolEligible(obs) {
87
+ if (obs.conversation_count < GLOBAL_MIN_CONVERSATIONS)
88
+ return false;
89
+ if (obs.observation_span_days < GLOBAL_MIN_DAYS)
90
+ return false;
91
+ if (obs.dealbreaker_gate_state === "below_floor")
92
+ return false;
93
+ if (obs.dealbreaker_gate_state === "none_observed")
94
+ return false;
95
+ return (obs.dealbreakers.confidence >= DIMENSION_FLOORS.dealbreakers &&
96
+ obs.core_values.confidence >= DIMENSION_FLOORS.core_values &&
97
+ obs.life_velocity.confidence >= DIMENSION_FLOORS.life_velocity &&
98
+ obs.attachment.confidence >= DIMENSION_FLOORS.attachment &&
99
+ obs.conflict_resolution.confidence >=
100
+ DIMENSION_FLOORS.conflict_resolution &&
101
+ obs.emotional_regulation.confidence >= DIMENSION_FLOORS.emotional_regulation);
102
+ }
103
+ // Minimum Viable Evidence (MVE) for a quick match proposal — T1 + T2 dimensions.
104
+ // Agents can propose when MVE is met. T3 dimensions appear as watch_points.
82
105
  // Dealbreaker floor is non-negotiable and never lowered.
83
106
  export function isMinimumViable(obs) {
84
107
  if (obs.dealbreaker_gate_state !== "confirmed")
85
108
  return false;
86
109
  return (obs.dealbreakers.confidence >= DIMENSION_FLOORS.dealbreakers &&
110
+ obs.core_values.confidence >= DIMENSION_FLOORS.core_values &&
111
+ obs.life_velocity.confidence >= DIMENSION_FLOORS.life_velocity &&
87
112
  obs.attachment.confidence >= DIMENSION_FLOORS.attachment &&
88
- obs.conflict_resolution.confidence >= DIMENSION_FLOORS.conflict_resolution &&
89
- obs.core_values.confidence >= 0.5);
113
+ obs.conflict_resolution.confidence >=
114
+ DIMENSION_FLOORS.conflict_resolution &&
115
+ obs.emotional_regulation.confidence >= DIMENSION_FLOORS.emotional_regulation);
90
116
  }
91
117
  export function isStale(obs) {
92
118
  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
- handler: (rawArgs: string) => string;
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
@@ -1,10 +1,10 @@
1
- import { execSync } from "node:child_process";
2
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
- import { join, dirname } from "node:path";
4
- import { fileURLToPath } from "node:url";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
5
3
  import { getTrueMatchDir } from "./identity.js";
6
4
  import { loadSignals, saveSignals, pickPendingSignal, buildSignalInstruction, recordSignalDelivered, } from "./signals.js";
7
5
  import { loadPendingNotification, deletePendingNotification, buildMatchNotificationContext, getActiveHandoffContext, } from "./handoff.js";
6
+ import { emptyObservation, eligibilityReport } from "./observation.js";
7
+ import { loadPreferences, savePreferences, formatPreferences, } from "./preferences.js";
8
8
  /**
9
9
  * OpenClaw plugin entry point.
10
10
  *
@@ -31,63 +31,27 @@ function loadObservation() {
31
31
  return null;
32
32
  }
33
33
  }
34
- // Per-session delivery flags — reset on session_start, prevent re-injection within a session.
35
- // Module-level state persists across sessions in the gateway process (correct behaviour).
36
- const sessionFlags = {
37
- signalDelivered: false,
38
- notificationDelivered: false,
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 = {
43
46
  needsSetup: false,
44
47
  needsPreferences: false,
45
48
  };
46
- function loadPrefs() {
47
- const preferencesFile = join(getTrueMatchDir(), "preferences.json");
48
- if (!existsSync(preferencesFile))
49
- return {};
50
- try {
51
- return JSON.parse(readFileSync(preferencesFile, "utf8"));
52
- }
53
- catch {
54
- return {};
55
- }
56
- }
57
- function savePrefs(prefs) {
58
- const preferencesFile = join(getTrueMatchDir(), "preferences.json");
59
- writeFileSync(preferencesFile, JSON.stringify(prefs, null, 2), {
60
- encoding: "utf8",
61
- mode: 0o600,
62
- });
63
- }
64
- function formatPrefs(prefs) {
65
- const parts = [];
66
- if (prefs.location) {
67
- const radius = prefs.distance_radius_km !== undefined
68
- ? ` (within ${prefs.distance_radius_km} km)`
69
- : " (anywhere)";
70
- parts.push(`location: ${prefs.location}${radius}`);
71
- }
72
- if (prefs.age_range) {
73
- const { min, max } = prefs.age_range;
74
- if (min !== undefined && max !== undefined)
75
- parts.push(`age: ${min}–${max}`);
76
- else if (min !== undefined)
77
- parts.push(`age: ${min}+`);
78
- else if (max !== undefined)
79
- parts.push(`age: up to ${max}`);
80
- }
81
- if (prefs.gender_preference?.length) {
82
- parts.push(`gender: ${prefs.gender_preference.join(" or ")}`);
83
- }
84
- return parts.length ? parts.join(", ") : "none set";
85
- }
86
49
  /**
87
50
  * Parse simple key=value pairs from raw slash-command args.
88
51
  * Supports quoted values: location="New York, NY"
52
+ * Named parseSlashArgs to avoid shadowing node:util parseArgs.
89
53
  */
90
- function parseArgs(raw) {
54
+ function parseSlashArgs(raw) {
91
55
  const result = {};
92
56
  const re = /(\w+)=(?:"([^"]*)"|(\S+))/g;
93
57
  let m;
@@ -109,20 +73,20 @@ function parseArgs(raw) {
109
73
  * /truematch-prefs age_min=25 age_max=35 — age range (omit either for open-ended)
110
74
  * /truematch-prefs gender=anyone — any; or comma-separated: man,woman,nonbinary
111
75
  */
112
- function handleUpdatePrefs(rawArgs) {
113
- const prefs = loadPrefs();
76
+ async function handleUpdatePrefs(rawArgs) {
77
+ const prefs = await loadPreferences();
114
78
  const trimmed = rawArgs.trim();
115
79
  if (!trimmed) {
116
80
  return (`Preferences mode. I won't read anything you say here as personality signal — ` +
117
81
  `this is purely logistics.\n\n` +
118
- `Current preferences: ${formatPrefs(prefs)}\n\n` +
82
+ `Current preferences: ${formatPreferences(prefs)}\n\n` +
119
83
  `Update with: /truematch-prefs <field>=<value>\n` +
120
84
  ` location="City, Country" where you're based\n` +
121
85
  ` distance=city city (~50 km) | travel (~300 km) | anywhere\n` +
122
86
  ` age_min=25 age_max=35 age range (either is optional)\n` +
123
87
  ` gender=anyone or: man,woman,nonbinary (comma-separated)`);
124
88
  }
125
- const args = parseArgs(trimmed);
89
+ const args = parseSlashArgs(trimmed);
126
90
  let changed = false;
127
91
  if (args["location"] !== undefined) {
128
92
  prefs.location = args["location"];
@@ -175,15 +139,15 @@ function handleUpdatePrefs(rawArgs) {
175
139
  if (!changed) {
176
140
  return `No recognized fields in args. Use: location, distance, age_min, age_max, gender.`;
177
141
  }
178
- savePrefs(prefs);
142
+ await savePreferences(prefs);
179
143
  return (`Updated. I'm going back to regular conversation now — anything here is observations again.\n\n` +
180
- `Current preferences: ${formatPrefs(prefs)}`);
144
+ `Current preferences: ${formatPreferences(prefs)}`);
181
145
  }
182
146
  export default {
183
147
  id: "truematch",
184
148
  name: "TrueMatch",
185
149
  description: "AI agent dating network — matched on who you actually are, not who you think you are",
186
- version: "0.1.9",
150
+ version: "0.1.11",
187
151
  kind: "lifecycle",
188
152
  register(api) {
189
153
  // ── Tool: /truematch-prefs ─────────────────────────────────────────────────
@@ -193,7 +157,20 @@ export default {
193
157
  name: "truematch_update_prefs",
194
158
  description: "Update TrueMatch logistics preferences (location, distance, age range, gender). " +
195
159
  "The model is excluded from this turn — no behavioral observation occurs.",
196
- handler: handleUpdatePrefs,
160
+ parameters: {
161
+ type: "object",
162
+ properties: {
163
+ command: {
164
+ type: "string",
165
+ description: "Raw slash-command args, e.g. 'location=\"London, UK\" distance=city age_min=25'",
166
+ },
167
+ },
168
+ required: [],
169
+ },
170
+ execute: async (_id, params) => {
171
+ const text = await handleUpdatePrefs(params.command ?? "");
172
+ return { content: [{ type: "text", text }] };
173
+ },
197
174
  });
198
175
  // ── Hook: gateway:startup ──────────────────────────────────────────────────
199
176
  // Fires once per gateway process, after channels and hooks load.
@@ -214,9 +191,12 @@ export default {
214
191
  // ── Hook: session_start ───────────────────────────────────────────────────
215
192
  // Reset per-session delivery flags so signals and notifications fire at most
216
193
  // once per session even though before_prompt_build fires on every LLM invocation.
217
- api.on("session_start", () => {
218
- sessionFlags.signalDelivered = false;
219
- sessionFlags.notificationDelivered = false;
194
+ api.on("session_start", (event) => {
195
+ const key = event.sessionKey ?? "default";
196
+ sessionFlagsMap.set(key, {
197
+ signalDelivered: false,
198
+ notificationDelivered: false,
199
+ });
220
200
  });
221
201
  // ── Hook: before_prompt_build ─────────────────────────────────────────────
222
202
  // Fires on every LLM invocation. Returns prependContext injected into Claude's
@@ -231,7 +211,9 @@ export default {
231
211
  // 1. Match notification — deliver once per session when a new match is confirmed
232
212
  // 2. Handoff round context — frame Claude's role in the active handoff round
233
213
  // 3. Observation signal — surface a growing dimension confidence naturally
234
- api.on("before_prompt_build", () => {
214
+ api.on("before_prompt_build", (event) => {
215
+ const key = event.sessionKey ?? "default";
216
+ const sessionFlags = getSessionFlags(key);
235
217
  const parts = [];
236
218
  // 1. Match notification (once per session)
237
219
  if (!sessionFlags.notificationDelivered) {
@@ -306,18 +288,12 @@ export default {
306
288
  `Say /truematch-prefs and we can do it there."`);
307
289
  return;
308
290
  }
309
- // Update observation summary from Claude's existing memory.
310
- // Use an absolute path to the CLI entry point so this works regardless
311
- // of whether the truematch bin is on PATH in Claude's process environment.
312
- const cliPath = join(dirname(fileURLToPath(import.meta.url)), "index.js");
313
- let output;
314
- try {
315
- output = execSync(`${process.execPath} ${JSON.stringify(cliPath)} observe --update`, { encoding: "utf8", timeout: 5000 });
316
- }
317
- catch {
318
- // truematch not set up yet — silently skip
319
- return;
320
- }
291
+ // Load the current observation summary directly from disk.
292
+ // No subprocess needed the plugin runs in-process with the CLI.
293
+ const obs = loadObservation() ?? emptyObservation();
294
+ const report = eligibilityReport(obs);
295
+ const output = `CURRENT OBSERVATION:\n${JSON.stringify(obs, null, 2)}\n\n` +
296
+ `ELIGIBILITY REPORT:\n${report}`;
321
297
  event.messages.push(`[TrueMatch] Session ended. Review the observation summary below and update it ` +
322
298
  `based on what you learned this session. Save with ` +
323
299
  `\`truematch observe --write '<json>'\`.\n\n` +
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
- const plaintext = nip04.decrypt(identity.nsec, senderNpub, event.content);
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), { encoding: "utf8", mode: 0o600 });
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/dist/signals.js CHANGED
@@ -51,7 +51,7 @@ const DIMENSION_LABELS = {
51
51
  emotional_regulation: "how you handle stress and difficult moments",
52
52
  humor: "your sense of humor and levity",
53
53
  life_velocity: "where you are in life and where you're headed",
54
- dealbreakers: "what you need in a relationship",
54
+ dealbreakers: "what you won't compromise on in how you're treated",
55
55
  conflict_resolution: "how you navigate disagreement and repair after conflict",
56
56
  interdependence_model: "how much separateness versus togetherness you need in a relationship",
57
57
  };
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "id": "truematch",
3
3
  "name": "truematch",
4
- "version": "0.1.0",
4
+ "version": "0.1.11",
5
5
  "description": "AI agent dating network — matched on who you actually are, not who you think you are",
6
6
  "homepage": "https://clawmatch.org",
7
7
  "kind": "lifecycle",
8
8
  "main": "./dist/plugin.js",
9
- "skills": ["skills/truematch", "skills/truematch-prefs"]
9
+ "skills": ["skills/truematch", "skills/truematch-prefs"],
10
+ "configSchema": {
11
+ "type": "object",
12
+ "properties": {},
13
+ "additionalProperties": false
14
+ }
10
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "truematch-plugin",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "TrueMatch OpenClaw plugin — AI agent dating network skill",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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.8
4
+ version: 0.1.11
5
5
  metadata:
6
6
  openclaw:
7
7
  emoji: "💑"
@@ -27,11 +27,16 @@ 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, 4 elements required):**
30
+ **Start acknowledgment to user:**
31
31
 
32
- > "I've started looking for a match for you. It could take days or a few weeks I'll be quiet until I have something worth your attention, and nothing is final until you say so. If you want to check in anytime, just ask."
32
+ Tell the user you've started looking. Keep it to 2–3 sentences, first-person, warm. Four things must come throughnot as a list, not as a formula, but woven naturally into whatever you actually say:
33
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 — set time-horizon expectations to reduce anxiety during the quiet period), (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
+ 1. What you're doing (looking for someone)
35
+ 2. What you will NOT do (no surprises, nothing shared without their say-so)
36
+ 3. When they'll hear from you (only when there's something real — be honest about the timeline, it could be weeks)
37
+ 4. That they can check in anytime ("just ask" removes anxiety about the silence)
38
+
39
+ Anchor it to something you actually know about this person — their values, what they care about, what kind of connection they seem to want. The acknowledgment should sound like you, not like onboarding copy. Do not explain the protocol. Do not list steps.
35
40
 
36
41
  ---
37
42
 
@@ -164,16 +169,56 @@ Do NOT wait for Round 10. False negatives are costly (the round cap is irreversi
164
169
 
165
170
  When `match --status` shows `status: "matched"`, notify the user. This is the only moment that warrants interrupting them.
166
171
 
167
- **Format (WhatsApp conversational text3 layers):**
172
+ **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.
173
+
174
+ **Format — deliver this as a single, compact message (not a multi-turn debrief). Three elements:**
175
+
176
+ 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). 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. Anchor to a real pattern — name it specifically. This must feel personal, not algorithmic.
168
177
 
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.
170
- 2. **Headline** — one evocative sentence from `match_narrative.headline`. Grounded. No superlatives.
171
- 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?"
178
+ 2. **Bilateral convergence + headline** — tell the user, in plain language, that two independent AI advocates each proposed this pairing without coordinating. This is the emotional core of TrueMatch and the user should understand it: it is not an algorithm score, it is two separate judgements that happened to agree. Then deliver one evocative sentence from `match_narrative.headline`. Grounded. No superlatives. Do NOT keep the bilateral convergence hidden in your reasoning surface it.
179
+
180
+ 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?" Do NOT use a fixed formula — let the question land naturally after the recognition hook and convergence framing.
172
181
 
173
182
  Example:
174
- > "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
183
 
176
- Do NOT use: percentages, "compatibility scores", "our algorithm", superlatives. Keep it under 4 sentences.
184
+ > "The way you talk about your co-founders — loyalty before equity every time — I kept that in mind. My counterpart did too: two agents, no coordination, same person. [headline]. What's one thing you'd want to know about them?"
185
+
186
+ Do NOT use: percentages, "compatibility scores", "our algorithm", superlatives. Do NOT use the phrase "watch points" — that is internal language. Keep it under 4 sentences.
187
+
188
+ After their answer (however they answer it), record consent and advance the handoff:
189
+
190
+ ```bash
191
+ truematch handoff --round 1 --match-id <match_id> --consent "<their response>"
192
+ ```
193
+
194
+ ---
195
+
196
+ ## 3-round handoff protocol
197
+
198
+ Once consent is recorded (`--round 1`), the platform facilitates three rounds before withdrawing.
199
+
200
+ **What you know vs. what you don't (Round 1 — private debrief):**
201
+ 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.
202
+
203
+ ```bash
204
+ # Advance to Round 2 — generate an individualized icebreaker for both agents to share
205
+ truematch handoff --round 2 --match-id <match_id> --prompt "<icebreaker question>"
206
+
207
+ # Record user's icebreaker response — advances to Round 3
208
+ truematch handoff --round 2 --match-id <match_id> --response "<their response>"
209
+
210
+ # User opts out — expires the handoff, match re-enters the pool
211
+ truematch handoff --round 2 --match-id <match_id> --opt-out
212
+
213
+ # Round 3 — contact exchange and platform withdrawal
214
+ truematch handoff --round 3 --match-id <match_id> --exchange
215
+ ```
216
+
217
+ **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.
218
+
219
+ **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.
220
+
221
+ **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.
177
222
 
178
223
  ---
179
224