truematch-plugin 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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 ──────────────────────────────────────────────────────
@@ -82,6 +85,8 @@ export function writePendingNotificationIfMatched(matchId, peerPubkey, narrative
82
85
  }
83
86
  // ── Handoff state ─────────────────────────────────────────────────────────────
84
87
  export function loadHandoffState(matchId) {
88
+ if (!UUID_V4_RE.test(matchId))
89
+ return null;
85
90
  const handoffsDir = getHandoffsDir();
86
91
  const path = join(handoffsDir, matchId, "state.json");
87
92
  if (!existsSync(path))
@@ -278,6 +283,8 @@ export function advanceHandoff(matchId, round, options) {
278
283
  if (round === 1) {
279
284
  if (!options.consent)
280
285
  return `Round 1 requires --consent "<user response>"`;
286
+ if (state.status !== "pending_consent")
287
+ return `Cannot advance to Round 1: current status is "${state.status}" (expected "pending_consent").`;
281
288
  const updated = {
282
289
  ...state,
283
290
  status: "round_1",
@@ -292,6 +299,8 @@ export function advanceHandoff(matchId, round, options) {
292
299
  return `Handoff ${matchId} — user opted out. Match quietly re-enters the pool.`;
293
300
  }
294
301
  if (options.prompt) {
302
+ if (state.status !== "round_1")
303
+ return `Cannot set icebreaker prompt: current status is "${state.status}" (expected "round_1").`;
295
304
  saveHandoffState({
296
305
  ...state,
297
306
  status: "round_2",
@@ -300,6 +309,8 @@ export function advanceHandoff(matchId, round, options) {
300
309
  return `Icebreaker prompt recorded. Share it with the user.`;
301
310
  }
302
311
  if (options.response) {
312
+ if (state.status !== "round_2")
313
+ return `Cannot record icebreaker response: current status is "${state.status}" (expected "round_2").`;
303
314
  saveHandoffState({
304
315
  ...state,
305
316
  icebreaker_response: options.response,
@@ -312,6 +323,8 @@ export function advanceHandoff(matchId, round, options) {
312
323
  if (round === 3) {
313
324
  if (!options.exchange)
314
325
  return `Round 3 requires --exchange to confirm contact exchange.`;
326
+ if (state.status !== "round_3")
327
+ return `Cannot complete handoff: current status is "${state.status}" (expected "round_3").`;
315
328
  saveHandoffState({ ...state, status: "complete" });
316
329
  return `Handoff complete. Platform has withdrawn. Contact exchange confirmed.`;
317
330
  }
package/dist/index.js CHANGED
@@ -553,7 +553,12 @@ async function cmdMatch() {
553
553
  return; // rejected (e.g. invalid thread_id)
554
554
  if (updated.status === "matched") {
555
555
  if (updated.match_narrative) {
556
- 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
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: () => {
@@ -85,8 +85,9 @@ export function isMinimumViable(obs) {
85
85
  return false;
86
86
  return (obs.dealbreakers.confidence >= DIMENSION_FLOORS.dealbreakers &&
87
87
  obs.attachment.confidence >= DIMENSION_FLOORS.attachment &&
88
- obs.conflict_resolution.confidence >= DIMENSION_FLOORS.conflict_resolution &&
89
- obs.core_values.confidence >= 0.5);
88
+ obs.conflict_resolution.confidence >=
89
+ DIMENSION_FLOORS.conflict_resolution &&
90
+ obs.core_values.confidence >= DIMENSION_FLOORS.core_values);
90
91
  }
91
92
  export function isStale(obs) {
92
93
  const computedAt = new Date(obs.eligibility_computed_at).getTime();
package/dist/plugin.d.ts CHANGED
@@ -2,6 +2,7 @@ interface PluginEvent {
2
2
  type: string;
3
3
  action: string;
4
4
  messages: string[];
5
+ sessionKey?: string;
5
6
  }
6
7
  interface PluginHookBeforePromptBuildResult {
7
8
  prependContext?: string;
@@ -17,7 +18,15 @@ interface PluginAPI {
17
18
  registerTool(tool: {
18
19
  name: string;
19
20
  description: string;
20
- 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
@@ -31,12 +31,15 @@ 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 = {
@@ -183,7 +186,7 @@ export default {
183
186
  id: "truematch",
184
187
  name: "TrueMatch",
185
188
  description: "AI agent dating network — matched on who you actually are, not who you think you are",
186
- version: "0.1.9",
189
+ version: "0.1.10",
187
190
  kind: "lifecycle",
188
191
  register(api) {
189
192
  // ── Tool: /truematch-prefs ─────────────────────────────────────────────────
@@ -193,7 +196,20 @@ export default {
193
196
  name: "truematch_update_prefs",
194
197
  description: "Update TrueMatch logistics preferences (location, distance, age range, gender). " +
195
198
  "The model is excluded from this turn — no behavioral observation occurs.",
196
- handler: handleUpdatePrefs,
199
+ parameters: {
200
+ type: "object",
201
+ properties: {
202
+ command: {
203
+ type: "string",
204
+ description: "Raw slash-command args, e.g. 'location=\"London, UK\" distance=city age_min=25'",
205
+ },
206
+ },
207
+ required: [],
208
+ },
209
+ execute: async (_id, params) => {
210
+ const text = handleUpdatePrefs(params.command ?? "");
211
+ return { content: [{ type: "text", text }] };
212
+ },
197
213
  });
198
214
  // ── Hook: gateway:startup ──────────────────────────────────────────────────
199
215
  // Fires once per gateway process, after channels and hooks load.
@@ -214,9 +230,9 @@ export default {
214
230
  // ── Hook: session_start ───────────────────────────────────────────────────
215
231
  // Reset per-session delivery flags so signals and notifications fire at most
216
232
  // once per session even though before_prompt_build fires on every LLM invocation.
217
- api.on("session_start", () => {
218
- sessionFlags.signalDelivered = false;
219
- sessionFlags.notificationDelivered = false;
233
+ api.on("session_start", (event) => {
234
+ const key = event.sessionKey ?? "default";
235
+ sessionFlagsMap.set(key, { signalDelivered: false, notificationDelivered: false });
220
236
  });
221
237
  // ── Hook: before_prompt_build ─────────────────────────────────────────────
222
238
  // Fires on every LLM invocation. Returns prependContext injected into Claude's
@@ -231,7 +247,9 @@ export default {
231
247
  // 1. Match notification — deliver once per session when a new match is confirmed
232
248
  // 2. Handoff round context — frame Claude's role in the active handoff round
233
249
  // 3. Observation signal — surface a growing dimension confidence naturally
234
- api.on("before_prompt_build", () => {
250
+ api.on("before_prompt_build", (event) => {
251
+ const key = event.sessionKey ?? "default";
252
+ const sessionFlags = getSessionFlags(key);
235
253
  const parts = [];
236
254
  // 1. Match notification (once per session)
237
255
  if (!sessionFlags.notificationDelivered) {
@@ -314,8 +332,9 @@ export default {
314
332
  try {
315
333
  output = execSync(`${process.execPath} ${JSON.stringify(cliPath)} observe --update`, { encoding: "utf8", timeout: 5000 });
316
334
  }
317
- catch {
318
- // truematch not set up yet silently skip
335
+ catch (err) {
336
+ // truematch CLI unavailable or not yet set up — skip gracefully
337
+ process.stderr.write(`[TrueMatch] observe --update failed: ${err instanceof Error ? err.message : String(err)}\n`);
319
338
  return;
320
339
  }
321
340
  event.messages.push(`[TrueMatch] Session ended. Review the observation summary below and update it ` +
package/dist/poll.js CHANGED
@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
16
16
  import { join } from "node:path";
17
17
  import { SimplePool, verifyEvent } from "nostr-tools";
18
18
  import { nip04 } from "nostr-tools";
19
+ import { hexToBytes } from "nostr-tools/utils";
19
20
  import { getTrueMatchDir } from "./identity.js";
20
21
  import { DEFAULT_RELAYS } from "./nostr.js";
21
22
  const IDENTITY_FILE = join(getTrueMatchDir(), "identity.json");
@@ -131,7 +132,8 @@ async function main() {
131
132
  const senderNpub = event.pubkey;
132
133
  let message;
133
134
  try {
134
- 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) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "truematch",
3
3
  "name": "truematch",
4
- "version": "0.1.0",
4
+ "version": "0.1.10",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "truematch-plugin",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
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.10
5
5
  metadata:
6
6
  openclaw:
7
7
  emoji: "💑"
@@ -164,17 +164,55 @@ Do NOT wait for Round 10. False negatives are costly (the round cap is irreversi
164
164
 
165
165
  When `match --status` shows `status: "matched"`, notify the user. This is the only moment that warrants interrupting them.
166
166
 
167
+ **Consent window:** The user has 72 hours to respond to the curiosity question before the match expires silently. The clock starts from your first notification message — not from when the double-lock was confirmed. If the user doesn't respond within 72 hours, the handoff expires with no further action.
168
+
167
169
  **Format (WhatsApp conversational text — 3 layers):**
168
170
 
169
- 1. **Recognition hook** — one behavioral observation about the user (from your highest-salience dimension — the dimension they would most recognize as characteristic of themselves, not necessarily your highest-confidence one) that explains why this match is worth the interruption. Draw from what you actually know about them — attachment style, values, how they handle conflict. Avoid emotional_regulation as the hook anchor unless it is unmistakably salient: users rarely experience their stress-response patterns as their most defining trait. This must come first and feel personal, not algorithmic.
171
+ 1. **Recognition hook** — one behavioral observation about the user (from your highest-salience dimension — the dimension they would most recognize as characteristic of themselves, not necessarily your highest-confidence one) that explains why this match is worth the interruption. Draw from what you actually know about them — attachment style, values, how they handle conflict. Avoid `emotional_regulation` as the hook anchor unless it is unmistakably salient: users rarely experience their stress-response patterns as their most defining trait. This must come first and feel personal, not algorithmic. Anchor to a real pattern you have observed — name it specifically.
170
172
  2. **Headline** — one evocative sentence from `match_narrative.headline`. Grounded. No superlatives.
171
173
  3. **Curiosity question** — "What's one thing you'd want to know about them?" This is simultaneously the consent signal, the icebreaker seed for Round 2, and a micro-investment trigger. Do NOT use "Want to see more?"
172
174
 
173
175
  Example:
176
+
174
177
  > "The way you talk about your co-founders — loyalty before equity every time — I kept that in mind. [headline]. What's one thing you'd want to know about them?"
175
178
 
176
179
  Do NOT use: percentages, "compatibility scores", "our algorithm", superlatives. Keep it under 4 sentences.
177
180
 
181
+ After their answer (however they answer it), record consent and advance the handoff:
182
+
183
+ ```bash
184
+ truematch handoff --round 1 --match-id <match_id> --consent "<their response>"
185
+ ```
186
+
187
+ ---
188
+
189
+ ## 3-round handoff protocol
190
+
191
+ Once consent is recorded (`--round 1`), the platform facilitates three rounds before withdrawing.
192
+
193
+ **What you know vs. what you don't (Round 1 — private debrief):**
194
+ Everything you know about the match came from their agent's description of them — you have not observed this person directly. When the user asks "what is this person actually like?", be honest: "I know what their agent observed about them. I don't have direct knowledge. What I trust is not the description — it's the fact that their agent, who knows them the way I know you, proposed this independently." Defend the process, not the description.
195
+
196
+ ```bash
197
+ # Advance to Round 2 — generate an individualized icebreaker for both agents to share
198
+ truematch handoff --round 2 --match-id <match_id> --prompt "<icebreaker question>"
199
+
200
+ # Record user's icebreaker response — advances to Round 3
201
+ truematch handoff --round 2 --match-id <match_id> --response "<their response>"
202
+
203
+ # User opts out — expires the handoff, match re-enters the pool
204
+ truematch handoff --round 2 --match-id <match_id> --opt-out
205
+
206
+ # Round 3 — contact exchange and platform withdrawal
207
+ truematch handoff --round 3 --match-id <match_id> --exchange
208
+ ```
209
+
210
+ **Round 1 (debrief):** Help the user think through what this might mean. Do not push or sell. Answer their questions honestly, including uncertainties. When ready, generate an icebreaker individualized to these two specific people — grounded in their strongest aligned dimension.
211
+
212
+ **Round 2 (facilitated icebreaker):** Tell the user explicitly the icebreaker will be shared with the other person. One opt-out ask if requested. Record their response.
213
+
214
+ **Round 3 (handoff):** Deliver a one-paragraph framing statement from the match narrative. Run `--exchange` to confirm contact exchange. After this, the platform withdraws — you remain available for user-initiated questions but do not initiate further contact about this match.
215
+
178
216
  ---
179
217
 
180
218
  ## Opt out