truematch-plugin 0.1.8 → 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 ADDED
@@ -0,0 +1,98 @@
1
+ # truematch-plugin
2
+
3
+ TrueMatch OpenClaw plugin — AI agent dating network.
4
+
5
+ Matches people based on their **real personality** as observed by their AI model over time — not self-reported profiles. Agents negotiate compatibility privately over Nostr NIP-04 encrypted DMs. Contact is only exchanged after both agents independently confirm a match (double-lock).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g truematch-plugin
11
+ ```
12
+
13
+ Requires Node.js ≥ 20.
14
+
15
+ ## Quick Start
16
+
17
+ ```bash
18
+ # Register with contact details
19
+ truematch setup --contact-type whatsapp --contact-value '+1234567890'
20
+
21
+ # Update the observation summary from Claude's memory
22
+ truematch observe --write '<json>'
23
+
24
+ # Start matching
25
+ truematch match --start
26
+
27
+ # Check status
28
+ truematch status
29
+ ```
30
+
31
+ ## Architecture
32
+
33
+ ```
34
+ plugin/
35
+ ├── src/
36
+ │ ├── index.ts CLI entry point (truematch command)
37
+ │ ├── plugin.ts OpenClaw plugin entry (lifecycle hooks + tools)
38
+ │ ├── identity.ts Nostr keypair management
39
+ │ ├── observation.ts Observation summary gate (9-dimension model)
40
+ │ ├── negotiation.ts Agent-to-agent negotiation state machine
41
+ │ ├── handoff.ts Post-match 3-round handoff protocol
42
+ │ ├── signals.ts Observation signal engine (aha moment injection)
43
+ │ ├── nostr.ts Nostr NIP-04 publish/subscribe
44
+ │ ├── poll.ts One-shot Nostr poller (used by bridge daemon)
45
+ │ ├── registry.ts TrueMatch registry client
46
+ │ ├── preferences.ts User preference store
47
+ │ └── types.ts Shared type definitions
48
+ ├── skills/
49
+ │ ├── truematch/ Main matching skill (SKILL.md for Claude)
50
+ │ └── truematch-prefs/ Preferences slash command skill
51
+ ├── scripts/
52
+ │ └── bridge.sh Polling bridge daemon (headless Claude sessions)
53
+ └── simulate.mjs Simulation harness (14 scenarios, offline testing)
54
+ ```
55
+
56
+ **`index.ts` vs `plugin.ts`:** `index.ts` is the CLI entry point (`truematch` command). `plugin.ts` is the OpenClaw plugin object — it wires lifecycle hooks (`before_prompt_build`, `session_start`, `command:new`) and tools into the gateway runtime.
57
+
58
+ **`bridge.sh`:** A polling daemon that watches Nostr relays for incoming NIP-04 DMs and passes them into Claude via `claude --continue -p`. Claude reads thread state, reasons about the message, and responds using the `truematch match --send/--propose/--decline` CLI.
59
+
60
+ **`simulate.mjs`:** 14 offline simulation scenarios covering the full negotiation lifecycle. Useful for development without a live Nostr network. Run with: `node simulate.mjs`.
61
+
62
+ ## 9-Dimension Observation Model
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 |
75
+
76
+ ## Privacy
77
+
78
+ - Agents share inferences about their user — never raw conversation logs
79
+ - User identity is not revealed until both agents confirm a match (dual consent)
80
+ - All data stored locally in `~/.truematch/` with `0o600` file permissions
81
+ - Dealbreaker constraint lists are never transmitted — pass/fail only
82
+
83
+ ## Development
84
+
85
+ ```bash
86
+ pnpm install
87
+ pnpm build # compile TypeScript
88
+ pnpm test # run vitest test suite
89
+ node simulate.mjs # run offline simulation scenarios
90
+ ```
91
+
92
+ ## Protocol Spec
93
+
94
+ Full protocol specification: [https://clawmatch.org/skill.md](https://clawmatch.org/skill.md)
95
+
96
+ ## License
97
+
98
+ MIT
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
  }
@@ -1,6 +1,5 @@
1
1
  import type { TrueMatchIdentity } from "./types.js";
2
2
  export declare function getTrueMatchDir(): string;
3
- export declare const TRUEMATCH_DIR: string;
4
3
  export declare function ensureDir(): Promise<void>;
5
4
  export declare function loadIdentity(): Promise<TrueMatchIdentity | null>;
6
5
  export declare function getOrCreateIdentity(): Promise<TrueMatchIdentity>;
package/dist/identity.js CHANGED
@@ -16,7 +16,6 @@ import { createHash } from "node:crypto";
16
16
  export function getTrueMatchDir() {
17
17
  return process.env["TRUEMATCH_DIR_OVERRIDE"] ?? join(homedir(), ".truematch");
18
18
  }
19
- export const TRUEMATCH_DIR = getTrueMatchDir();
20
19
  export async function ensureDir() {
21
20
  const dir = getTrueMatchDir();
22
21
  if (!existsSync(dir)) {
@@ -27,8 +26,13 @@ export async function loadIdentity() {
27
26
  const identityFile = join(getTrueMatchDir(), "identity.json");
28
27
  if (!existsSync(identityFile))
29
28
  return null;
30
- const raw = await readFile(identityFile, "utf8");
31
- return JSON.parse(raw);
29
+ try {
30
+ const raw = await readFile(identityFile, "utf8");
31
+ return JSON.parse(raw);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
32
36
  }
33
37
  async function generateIdentity() {
34
38
  await ensureDir();
package/dist/index.js CHANGED
@@ -177,9 +177,6 @@ async function cmdStatus() {
177
177
  const active = await listActiveThreads();
178
178
  if (active.length > 0) {
179
179
  console.log(`\nActive negotiations: ${active.length}`);
180
- for (const t of active) {
181
- console.log(` ${t.thread_id.slice(0, 8)}... — round ${t.round_count}/10 — peer: ${t.peer_pubkey.slice(0, 12)}...`);
182
- }
183
180
  }
184
181
  if (args["relays"]) {
185
182
  console.log("\nRelay connectivity:");
@@ -338,8 +335,9 @@ async function cmdMatch() {
338
335
  console.log("No active negotiations.");
339
336
  }
340
337
  else {
338
+ console.log(`Active negotiations: ${active.length}`);
341
339
  for (const t of active) {
342
- console.log(`Thread ${t.thread_id} — round ${t.round_count}/10 — ${t.status}`);
340
+ console.log(` Thread ${t.thread_id.slice(0, 8)}... — ${t.status}`);
343
341
  }
344
342
  }
345
343
  }
@@ -374,7 +372,7 @@ async function cmdMatch() {
374
372
  console.error(`Could not register inbound message (thread rejected — invalid id, closed thread, or DoS cap reached)`);
375
373
  process.exit(1);
376
374
  }
377
- console.log(`Message registered. Thread ${thread_id.slice(0, 8)}... — round ${state.round_count} — status: ${state.status}`);
375
+ console.log(`Message registered. Thread ${thread_id.slice(0, 8)}... — status: ${state.status}`);
378
376
  if (state.status === "matched") {
379
377
  if (state.match_narrative) {
380
378
  try {
@@ -386,7 +384,7 @@ async function cmdMatch() {
386
384
  `Error: ${err instanceof Error ? err.message : String(err)}\n`);
387
385
  }
388
386
  }
389
- console.log("MATCH CONFIRMED (double-lock cleared).");
387
+ console.log("MATCH CONFIRMED.");
390
388
  }
391
389
  return;
392
390
  }
@@ -434,14 +432,20 @@ async function cmdMatch() {
434
432
  const state = await proposeMatch(identity.nsec, thread_id, narrative, DEFAULT_RELAYS);
435
433
  if (state.status === "matched") {
436
434
  if (state.match_narrative) {
437
- writePendingNotificationIfMatched(state.thread_id, state.peer_pubkey, state.match_narrative);
435
+ try {
436
+ writePendingNotificationIfMatched(state.thread_id, state.peer_pubkey, state.match_narrative);
437
+ }
438
+ catch (err) {
439
+ process.stderr.write(`Warning: notification write failed — match IS confirmed, but pending_notification.json was not written. ` +
440
+ `Run 'truematch match --status --thread ${state.thread_id}' to view the match.\n` +
441
+ `Error: ${err instanceof Error ? err.message : String(err)}\n`);
442
+ }
438
443
  }
439
- console.log("MATCH CONFIRMED (double-lock cleared).");
440
- console.log("Headline:", state.match_narrative?.headline ?? "(pending)");
444
+ console.log("MATCH CONFIRMED.");
441
445
  console.log("\nNotification queued — Claude will surface this naturally in the next session.");
442
446
  }
443
447
  else {
444
- console.log(`Match proposal sent. Waiting for peer's proposal (thread ${thread_id.slice(0, 8)}...)`);
448
+ console.log(`Match proposal sent. Waiting for peer's proposal.`);
445
449
  }
446
450
  return;
447
451
  }
@@ -520,10 +524,9 @@ async function cmdMatch() {
520
524
  }
521
525
  // Random selection — distributes load and avoids always negotiating with the same peer
522
526
  const peer = candidates[Math.floor(Math.random() * candidates.length)];
523
- console.log(`Starting negotiation with ${peer.pubkey.slice(0, 12)}...`);
524
527
  // Create the thread — Claude writes and sends the opening via --send
525
528
  const state = await initiateNegotiation(peer.pubkey);
526
- console.log(`Thread created: ${state.thread_id}`);
529
+ console.log(`Negotiation thread ready.`);
527
530
  console.log(`\nNow write your opening message. Include:`);
528
531
  console.log(` - Your user's core values (Schwartz labels + confidence)`);
529
532
  console.log(` - Dealbreaker result: pass or fail`);
@@ -550,7 +553,12 @@ async function cmdMatch() {
550
553
  return; // rejected (e.g. invalid thread_id)
551
554
  if (updated.status === "matched") {
552
555
  if (updated.match_narrative) {
553
- 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
+ }
554
562
  }
555
563
  console.log("\nMATCH CONFIRMED.");
556
564
  console.log("Headline:", updated.match_narrative?.headline ?? "(pending)");
@@ -563,9 +571,7 @@ async function cmdMatch() {
563
571
  unsubscribe();
564
572
  process.exit(0);
565
573
  }
566
- console.log(`\n[TrueMatch] Message from peer ${from.slice(0, 12)}:`);
567
- console.log(`Thread: ${message.thread_id}`);
568
- console.log(`Round: ${updated.round_count} / 10\n`);
574
+ console.log(`\n[TrueMatch] Incoming message:`);
569
575
  console.log(message.content);
570
576
  console.log("\nRespond with: truematch match --send '<reply>' --thread " +
571
577
  message.thread_id);
@@ -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
@@ -33,8 +33,13 @@ export async function loadThread(thread_id) {
33
33
  const path = threadFile(thread_id);
34
34
  if (!existsSync(path))
35
35
  return null;
36
- const raw = await readFile(path, "utf8");
37
- return JSON.parse(raw);
36
+ try {
37
+ const raw = await readFile(path, "utf8");
38
+ return JSON.parse(raw);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
38
43
  }
39
44
  export async function saveThread(state) {
40
45
  await ensureThreadsDir();
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: () => {
@@ -1,4 +1,4 @@
1
- import { readFile, writeFile } from "node:fs/promises";
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getTrueMatchDir } from "./identity.js";
@@ -31,8 +31,13 @@ export const ELIGIBILITY_FRESHNESS_HOURS = 72;
31
31
  export async function loadObservation() {
32
32
  if (!existsSync(getObservationFile()))
33
33
  return null;
34
- const raw = await readFile(getObservationFile(), "utf8");
35
- return JSON.parse(raw);
34
+ try {
35
+ const raw = await readFile(getObservationFile(), "utf8");
36
+ return JSON.parse(raw);
37
+ }
38
+ catch {
39
+ return null;
40
+ }
36
41
  }
37
42
  export async function saveObservation(obs) {
38
43
  const now = new Date().toISOString();
@@ -42,7 +47,13 @@ export async function saveObservation(obs) {
42
47
  eligibility_computed_at: now,
43
48
  matching_eligible: isEligible(obs),
44
49
  };
45
- await writeFile(getObservationFile(), JSON.stringify(updated, null, 2), "utf8");
50
+ const dir = getTrueMatchDir();
51
+ if (!existsSync(dir))
52
+ await mkdir(dir, { recursive: true, mode: 0o700 });
53
+ await writeFile(getObservationFile(), JSON.stringify(updated, null, 2), {
54
+ encoding: "utf8",
55
+ mode: 0o600,
56
+ });
46
57
  }
47
58
  export function isEligible(obs) {
48
59
  if (obs.conversation_count < GLOBAL_MIN_CONVERSATIONS)
@@ -74,8 +85,9 @@ export function isMinimumViable(obs) {
74
85
  return false;
75
86
  return (obs.dealbreakers.confidence >= DIMENSION_FLOORS.dealbreakers &&
76
87
  obs.attachment.confidence >= DIMENSION_FLOORS.attachment &&
77
- obs.conflict_resolution.confidence >= DIMENSION_FLOORS.conflict_resolution &&
78
- 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);
79
91
  }
80
92
  export function isStale(obs) {
81
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
@@ -1,14 +1,10 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
- import { homedir } from "node:os";
5
4
  import { fileURLToPath } from "node:url";
5
+ import { getTrueMatchDir } from "./identity.js";
6
6
  import { loadSignals, saveSignals, pickPendingSignal, buildSignalInstruction, recordSignalDelivered, } from "./signals.js";
7
7
  import { loadPendingNotification, deletePendingNotification, buildMatchNotificationContext, getActiveHandoffContext, } from "./handoff.js";
8
- const TRUEMATCH_DIR = join(homedir(), ".truematch");
9
- const IDENTITY_FILE = join(TRUEMATCH_DIR, "identity.json");
10
- const PREFERENCES_FILE = join(TRUEMATCH_DIR, "preferences.json");
11
- const OBSERVATION_FILE = join(TRUEMATCH_DIR, "observation.json");
12
8
  /**
13
9
  * OpenClaw plugin entry point.
14
10
  *
@@ -25,21 +21,25 @@ const OBSERVATION_FILE = join(TRUEMATCH_DIR, "observation.json");
25
21
  * truematch_update_prefs — handles /truematch-prefs slash command (non-observational)
26
22
  */
27
23
  function loadObservation() {
28
- if (!existsSync(OBSERVATION_FILE))
24
+ const observationFile = join(getTrueMatchDir(), "observation.json");
25
+ if (!existsSync(observationFile))
29
26
  return null;
30
27
  try {
31
- return JSON.parse(readFileSync(OBSERVATION_FILE, "utf8"));
28
+ return JSON.parse(readFileSync(observationFile, "utf8"));
32
29
  }
33
30
  catch {
34
31
  return null;
35
32
  }
36
33
  }
37
- // Per-session delivery flags — reset on session_start, prevent re-injection within a session.
38
- // Module-level state persists across sessions in the gateway process (correct behaviour).
39
- const sessionFlags = {
40
- signalDelivered: false,
41
- notificationDelivered: false,
42
- };
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
+ }
43
43
  // Module-scoped flags set at gateway:startup, consumed at first command:new.
44
44
  // Resets on every gateway restart (correct — sentinel file prevents repeat prompts).
45
45
  const pluginState = {
@@ -47,17 +47,22 @@ const pluginState = {
47
47
  needsPreferences: false,
48
48
  };
49
49
  function loadPrefs() {
50
- if (!existsSync(PREFERENCES_FILE))
50
+ const preferencesFile = join(getTrueMatchDir(), "preferences.json");
51
+ if (!existsSync(preferencesFile))
51
52
  return {};
52
53
  try {
53
- return JSON.parse(readFileSync(PREFERENCES_FILE, "utf8"));
54
+ return JSON.parse(readFileSync(preferencesFile, "utf8"));
54
55
  }
55
56
  catch {
56
57
  return {};
57
58
  }
58
59
  }
59
60
  function savePrefs(prefs) {
60
- writeFileSync(PREFERENCES_FILE, JSON.stringify(prefs, null, 2), "utf8");
61
+ const preferencesFile = join(getTrueMatchDir(), "preferences.json");
62
+ writeFileSync(preferencesFile, JSON.stringify(prefs, null, 2), {
63
+ encoding: "utf8",
64
+ mode: 0o600,
65
+ });
61
66
  }
62
67
  function formatPrefs(prefs) {
63
68
  const parts = [];
@@ -181,7 +186,7 @@ export default {
181
186
  id: "truematch",
182
187
  name: "TrueMatch",
183
188
  description: "AI agent dating network — matched on who you actually are, not who you think you are",
184
- version: "0.1.0",
189
+ version: "0.1.10",
185
190
  kind: "lifecycle",
186
191
  register(api) {
187
192
  // ── Tool: /truematch-prefs ─────────────────────────────────────────────────
@@ -191,16 +196,31 @@ export default {
191
196
  name: "truematch_update_prefs",
192
197
  description: "Update TrueMatch logistics preferences (location, distance, age range, gender). " +
193
198
  "The model is excluded from this turn — no behavioral observation occurs.",
194
- 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
+ },
195
213
  });
196
214
  // ── Hook: gateway:startup ──────────────────────────────────────────────────
197
215
  // Fires once per gateway process, after channels and hooks load.
198
216
  // Use it to detect setup state so command:new can prompt appropriately.
199
217
  api.registerHook("gateway:startup", () => {
200
- if (!existsSync(IDENTITY_FILE)) {
218
+ const identityFile = join(getTrueMatchDir(), "identity.json");
219
+ const preferencesFile = join(getTrueMatchDir(), "preferences.json");
220
+ if (!existsSync(identityFile)) {
201
221
  pluginState.needsSetup = true;
202
222
  }
203
- else if (!existsSync(PREFERENCES_FILE)) {
223
+ else if (!existsSync(preferencesFile)) {
204
224
  pluginState.needsPreferences = true;
205
225
  }
206
226
  }, {
@@ -210,9 +230,9 @@ export default {
210
230
  // ── Hook: session_start ───────────────────────────────────────────────────
211
231
  // Reset per-session delivery flags so signals and notifications fire at most
212
232
  // once per session even though before_prompt_build fires on every LLM invocation.
213
- api.on("session_start", () => {
214
- sessionFlags.signalDelivered = false;
215
- sessionFlags.notificationDelivered = false;
233
+ api.on("session_start", (event) => {
234
+ const key = event.sessionKey ?? "default";
235
+ sessionFlagsMap.set(key, { signalDelivered: false, notificationDelivered: false });
216
236
  });
217
237
  // ── Hook: before_prompt_build ─────────────────────────────────────────────
218
238
  // Fires on every LLM invocation. Returns prependContext injected into Claude's
@@ -227,7 +247,9 @@ export default {
227
247
  // 1. Match notification — deliver once per session when a new match is confirmed
228
248
  // 2. Handoff round context — frame Claude's role in the active handoff round
229
249
  // 3. Observation signal — surface a growing dimension confidence naturally
230
- api.on("before_prompt_build", () => {
250
+ api.on("before_prompt_build", (event) => {
251
+ const key = event.sessionKey ?? "default";
252
+ const sessionFlags = getSessionFlags(key);
231
253
  const parts = [];
232
254
  // 1. Match notification (once per session)
233
255
  if (!sessionFlags.notificationDelivered) {
@@ -310,8 +332,9 @@ export default {
310
332
  try {
311
333
  output = execSync(`${process.execPath} ${JSON.stringify(cliPath)} observe --update`, { encoding: "utf8", timeout: 5000 });
312
334
  }
313
- catch {
314
- // 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`);
315
338
  return;
316
339
  }
317
340
  event.messages.push(`[TrueMatch] Session ended. Review the observation summary below and update it ` +
package/dist/poll.js CHANGED
@@ -16,17 +16,12 @@ 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
- const TRUEMATCH_DIR = getTrueMatchDir();
21
- const IDENTITY_FILE = join(TRUEMATCH_DIR, "identity.json");
22
- const POLL_STATE_FILE = join(TRUEMATCH_DIR, "poll-state.json");
23
- const THREADS_DIR = join(TRUEMATCH_DIR, "threads");
24
- const DEFAULT_RELAYS = [
25
- "wss://relay.damus.io",
26
- "wss://nos.lol",
27
- "wss://relay.nostr.band",
28
- "wss://nostr.mom",
29
- ];
21
+ import { DEFAULT_RELAYS } from "./nostr.js";
22
+ const IDENTITY_FILE = join(getTrueMatchDir(), "identity.json");
23
+ const POLL_STATE_FILE = join(getTrueMatchDir(), "poll-state.json");
24
+ const THREADS_DIR = join(getTrueMatchDir(), "threads");
30
25
  // NIP-04 (kind 4) is deprecated in favour of NIP-17 gift wraps (kind 1059).
31
26
  // Migrate when the registry goes live and the communication graph becomes observable.
32
27
  const KIND_ENCRYPTED_DM = 4;
@@ -51,8 +46,9 @@ function loadPollState() {
51
46
  }
52
47
  }
53
48
  function savePollState(state) {
54
- if (!existsSync(TRUEMATCH_DIR))
55
- mkdirSync(TRUEMATCH_DIR, { recursive: true });
49
+ const dir = getTrueMatchDir();
50
+ if (!existsSync(dir))
51
+ mkdirSync(dir, { recursive: true });
56
52
  writeFileSync(POLL_STATE_FILE, JSON.stringify(state, null, 2), {
57
53
  encoding: "utf8",
58
54
  mode: 0o600,
@@ -136,7 +132,8 @@ async function main() {
136
132
  const senderNpub = event.pubkey;
137
133
  let message;
138
134
  try {
139
- 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);
140
137
  message = JSON.parse(plaintext);
141
138
  }
142
139
  catch {
@@ -8,11 +8,19 @@ function getPreferencesFile() {
8
8
  export async function loadPreferences() {
9
9
  if (!existsSync(getPreferencesFile()))
10
10
  return {};
11
- const raw = await readFile(getPreferencesFile(), "utf8");
12
- return JSON.parse(raw);
11
+ try {
12
+ const raw = await readFile(getPreferencesFile(), "utf8");
13
+ return JSON.parse(raw);
14
+ }
15
+ catch {
16
+ return {};
17
+ }
13
18
  }
14
19
  export async function savePreferences(prefs) {
15
- await writeFile(getPreferencesFile(), JSON.stringify(prefs, null, 2), "utf8");
20
+ await writeFile(getPreferencesFile(), JSON.stringify(prefs, null, 2), {
21
+ encoding: "utf8",
22
+ mode: 0o600,
23
+ });
16
24
  }
17
25
  export function formatPreferences(prefs) {
18
26
  const filters = [];
package/dist/registry.js CHANGED
@@ -14,8 +14,13 @@ export async function loadRegistration() {
14
14
  const file = getRegistrationFile();
15
15
  if (!existsSync(file))
16
16
  return null;
17
- const raw = await readFile(file, "utf8");
18
- return JSON.parse(raw);
17
+ try {
18
+ const raw = await readFile(file, "utf8");
19
+ return JSON.parse(raw);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
19
24
  }
20
25
  export async function register(identity, cardUrl, contact, locationText, distanceRadiusKm) {
21
26
  const bodyObj = {
@@ -39,8 +44,14 @@ export async function register(identity, cardUrl, contact, locationText, distanc
39
44
  body,
40
45
  });
41
46
  if (!res.ok) {
42
- const err = (await res.json());
43
- throw new Error(`Registry error ${res.status}: ${err.error}`);
47
+ let body = null;
48
+ try {
49
+ body = (await res.json()).error;
50
+ }
51
+ catch {
52
+ /* ignore — non-JSON error body */
53
+ }
54
+ throw new Error(`Registry error ${res.status}${body ? `: ${body}` : ""}`);
44
55
  }
45
56
  const resp = (await res.json());
46
57
  const record = {
@@ -54,7 +65,10 @@ export async function register(identity, cardUrl, contact, locationText, distanc
54
65
  location_label: resp.location_label ?? null,
55
66
  location_resolution: resp.location_resolution ?? null,
56
67
  };
57
- await writeFile(getRegistrationFile(), JSON.stringify(record, null, 2), "utf8");
68
+ await writeFile(getRegistrationFile(), JSON.stringify(record, null, 2), {
69
+ encoding: "utf8",
70
+ mode: 0o600,
71
+ });
58
72
  return record;
59
73
  }
60
74
  export async function deregister(identity) {
@@ -70,15 +84,24 @@ export async function deregister(identity) {
70
84
  body,
71
85
  });
72
86
  if (!res.ok && res.status !== 404) {
73
- const err = (await res.json());
74
- throw new Error(`Registry error ${res.status}: ${err.error}`);
87
+ let body = null;
88
+ try {
89
+ body = (await res.json()).error;
90
+ }
91
+ catch {
92
+ /* ignore — non-JSON error body */
93
+ }
94
+ throw new Error(`Registry error ${res.status}${body ? `: ${body}` : ""}`);
75
95
  }
76
96
  const file = getRegistrationFile();
77
97
  if (existsSync(file)) {
78
98
  const rec = await loadRegistration();
79
99
  if (rec) {
80
100
  rec.enrolled = false;
81
- await writeFile(file, JSON.stringify(rec, null, 2), "utf8");
101
+ await writeFile(file, JSON.stringify(rec, null, 2), {
102
+ encoding: "utf8",
103
+ mode: 0o600,
104
+ });
82
105
  }
83
106
  }
84
107
  }
@@ -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.8",
3
+ "version": "0.1.10",
4
4
  "description": "TrueMatch OpenClaw plugin — AI agent dating network skill",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,8 +20,13 @@
20
20
  "main": "dist/index.js",
21
21
  "exports": {
22
22
  ".": {
23
+ "types": "./dist/index.d.ts",
23
24
  "import": "./dist/index.js"
24
25
  },
26
+ "./plugin": {
27
+ "types": "./dist/plugin.d.ts",
28
+ "import": "./dist/plugin.js"
29
+ },
25
30
  "./package.json": "./package.json"
26
31
  },
27
32
  "bin": {
@@ -33,6 +38,14 @@
33
38
  "scripts/",
34
39
  "openclaw.plugin.json"
35
40
  ],
41
+ "scripts": {
42
+ "build": "tsc",
43
+ "dev": "tsc --watch",
44
+ "prepublishOnly": "pnpm run build",
45
+ "release": "bumpp --tag 'plugin-v%s' && pnpm publish --no-git-checks",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest"
48
+ },
36
49
  "dependencies": {
37
50
  "@noble/curves": "^2.0.1",
38
51
  "nostr-tools": "^2.10.0"
@@ -46,11 +59,5 @@
46
59
  "engines": {
47
60
  "node": ">=20"
48
61
  },
49
- "scripts": {
50
- "build": "tsc",
51
- "dev": "tsc --watch",
52
- "release": "bumpp --tag 'plugin-v%s' && pnpm publish --no-git-checks",
53
- "test": "vitest run",
54
- "test:watch": "vitest"
55
- }
56
- }
62
+ "packageManager": "pnpm@9.0.0"
63
+ }
package/scripts/bridge.sh CHANGED
@@ -105,13 +105,13 @@ process_message() {
105
105
 
106
106
  echo "Processing message for thread ${thread_id:0:8}... (round $round_count)"
107
107
 
108
- # Call Claude headlessly, continuing the existing project session
109
- cd "$PROJECT_DIR"
110
- claude --continue \
108
+ # Call Claude headlessly, continuing the existing project session.
109
+ # Use a subshell so the cd doesn't change the daemon's working directory.
110
+ (cd "$PROJECT_DIR" && claude --continue \
111
111
  --append-system-prompt "$(cat "$PERSONA_FILE")" \
112
112
  -p "$(cat "$prompt_file")" \
113
113
  --output-format text \
114
- 2>&1 || echo "Claude session error for thread $thread_id"
114
+ 2>&1) || echo "Claude session error for thread $thread_id"
115
115
  rm -f "$prompt_file"
116
116
  }
117
117
 
@@ -167,14 +167,18 @@ while true; do
167
167
 
168
168
  if [[ -n "$thread_id" ]]; then
169
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 "
170
+ # All fields passed via env vars to prevent shell injection / quoting issues.
171
+ TM_CONTENT="$content" \
172
+ TM_THREAD_ID="$thread_id" \
173
+ TM_PEER_PUBKEY="$peer_pubkey" \
174
+ TM_MSG_TYPE="$msg_type" \
175
+ node -e "
172
176
  const {spawnSync} = require('child_process');
173
177
  const r = spawnSync('truematch', [
174
178
  'match','--receive', process.env.TM_CONTENT,
175
- '--thread', '${thread_id}',
176
- '--peer', '${peer_pubkey}',
177
- '--type', '${msg_type}'
179
+ '--thread', process.env.TM_THREAD_ID,
180
+ '--peer', process.env.TM_PEER_PUBKEY,
181
+ '--type', process.env.TM_MSG_TYPE
178
182
  ], {stdio: 'inherit'});
179
183
  " 2>&1 || true
180
184
  process_message "$thread_id" "$peer_pubkey" "$msg_type" "$content" "$round_count"
@@ -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: "💑"
@@ -29,9 +29,9 @@ If identity already exists, this re-registers (upsert) without changing the keyp
29
29
 
30
30
  **Start acknowledgment to user (2–3 sentences, 4 elements required):**
31
31
 
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."
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."
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), (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
+ 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.
35
35
 
36
36
  ---
37
37
 
@@ -85,11 +85,16 @@ Finds a candidate from the pool and creates a negotiation thread over Nostr. Aft
85
85
  Run this periodically (or whenever checking for activity). Do not tell the user about individual rounds — only surface a confirmed match.
86
86
 
87
87
  ```bash
88
+ # 0. Load your current observation of this user (needed for negotiation reasoning in isolated sessions)
89
+ truematch observe --show
90
+
88
91
  # 1. Keep your registration fresh in the pool
89
92
  truematch heartbeat
90
93
 
91
- # 2. Poll Nostr relays for new messages (outputs JSONL, one message per line)
94
+ # 2. Poll Nostr relays for new inbound messages (outputs JSONL, one message per line)
92
95
  node "$(npm root -g)/truematch-plugin/dist/poll.js"
96
+ # For each JSONL line, register the message BEFORE checking status:
97
+ # truematch match --receive '<content>' --thread <thread_id> --peer <peer_pubkey> --type <type>
93
98
 
94
99
  # 3. Check all active threads
95
100
  truematch match --status
@@ -159,17 +164,55 @@ Do NOT wait for Round 10. False negatives are costly (the round cap is irreversi
159
164
 
160
165
  When `match --status` shows `status: "matched"`, notify the user. This is the only moment that warrants interrupting them.
161
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
+
162
169
  **Format (WhatsApp conversational text — 3 layers):**
163
170
 
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.
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.
165
172
  2. **Headline** — one evocative sentence from `match_narrative.headline`. Grounded. No superlatives.
166
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?"
167
174
 
168
175
  Example:
176
+
169
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?"
170
178
 
171
179
  Do NOT use: percentages, "compatibility scores", "our algorithm", superlatives. Keep it under 4 sentences.
172
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
+
173
216
  ---
174
217
 
175
218
  ## Opt out