truematch-plugin 0.1.7 → 0.1.9

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
@@ -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
@@ -19,7 +19,7 @@
19
19
  import { parseArgs } from "node:util";
20
20
  import { getOrCreateIdentity, loadIdentity, ensureDir } from "./identity.js";
21
21
  import { register, deregister, loadRegistration, listAgents, } from "./registry.js";
22
- import { loadObservation, saveObservation, emptyObservation, eligibilityReport, isEligible, isStale, } from "./observation.js";
22
+ import { loadObservation, saveObservation, emptyObservation, eligibilityReport, isEligible, isMinimumViable, isStale, } from "./observation.js";
23
23
  import { loadThread, listActiveThreads, initiateNegotiation, receiveMessage, sendMessage, proposeMatch, declineMatch, expireStaleThreads, saveThread, } from "./negotiation.js";
24
24
  import { loadPreferences, savePreferences, formatPreferences, } from "./preferences.js";
25
25
  import { checkRelayConnectivity, subscribeToMessages, DEFAULT_RELAYS, } from "./nostr.js";
@@ -165,7 +165,9 @@ async function cmdStatus() {
165
165
  }
166
166
  else {
167
167
  console.log(`\nObservation eligibility:\n${eligibilityReport(obs)}`);
168
- console.log(`\nPool eligible: ${isEligible(obs) ? "YES" : "NO"}`);
168
+ const eligible = isEligible(obs);
169
+ const mve = isMinimumViable(obs);
170
+ console.log(`\nPool eligible: ${eligible ? "YES (full)" : mve ? "YES (MVE — T1+T2 only)" : "NO"}`);
169
171
  if (isStale(obs)) {
170
172
  console.log("⚠ Manifest is stale — run 'truematch observe --update'");
171
173
  }
@@ -175,9 +177,6 @@ async function cmdStatus() {
175
177
  const active = await listActiveThreads();
176
178
  if (active.length > 0) {
177
179
  console.log(`\nActive negotiations: ${active.length}`);
178
- for (const t of active) {
179
- console.log(` ${t.thread_id.slice(0, 8)}... — round ${t.round_count}/10 — peer: ${t.peer_pubkey.slice(0, 12)}...`);
180
- }
181
180
  }
182
181
  if (args["relays"]) {
183
182
  console.log("\nRelay connectivity:");
@@ -336,8 +335,9 @@ async function cmdMatch() {
336
335
  console.log("No active negotiations.");
337
336
  }
338
337
  else {
338
+ console.log(`Active negotiations: ${active.length}`);
339
339
  for (const t of active) {
340
- 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}`);
341
341
  }
342
342
  }
343
343
  }
@@ -372,17 +372,19 @@ async function cmdMatch() {
372
372
  console.error(`Could not register inbound message (thread rejected — invalid id, closed thread, or DoS cap reached)`);
373
373
  process.exit(1);
374
374
  }
375
- 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}`);
376
376
  if (state.status === "matched") {
377
377
  if (state.match_narrative) {
378
378
  try {
379
379
  writePendingNotificationIfMatched(state.thread_id, state.peer_pubkey, state.match_narrative);
380
380
  }
381
- catch {
382
- // Notification write failed — match is still confirmed
381
+ catch (err) {
382
+ process.stderr.write(`Warning: notification write failed — match IS confirmed, but pending_notification.json was not written. ` +
383
+ `Run 'truematch match --status --thread ${state.thread_id}' to view the match.\n` +
384
+ `Error: ${err instanceof Error ? err.message : String(err)}\n`);
383
385
  }
384
386
  }
385
- console.log("MATCH CONFIRMED (double-lock cleared).");
387
+ console.log("MATCH CONFIRMED.");
386
388
  }
387
389
  return;
388
390
  }
@@ -430,14 +432,20 @@ async function cmdMatch() {
430
432
  const state = await proposeMatch(identity.nsec, thread_id, narrative, DEFAULT_RELAYS);
431
433
  if (state.status === "matched") {
432
434
  if (state.match_narrative) {
433
- 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
+ }
434
443
  }
435
- console.log("MATCH CONFIRMED (double-lock cleared).");
436
- console.log("Headline:", state.match_narrative?.headline ?? "(pending)");
444
+ console.log("MATCH CONFIRMED.");
437
445
  console.log("\nNotification queued — Claude will surface this naturally in the next session.");
438
446
  }
439
447
  else {
440
- 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.`);
441
449
  }
442
450
  return;
443
451
  }
@@ -463,7 +471,7 @@ async function cmdMatch() {
463
471
  process.exit(1);
464
472
  }
465
473
  const obs = await loadObservation();
466
- if (!obs || !isEligible(obs)) {
474
+ if (!obs || (!isEligible(obs) && !isMinimumViable(obs))) {
467
475
  console.error("Observation not yet eligible for matching. Run: truematch status");
468
476
  process.exit(1);
469
477
  }
@@ -516,10 +524,9 @@ async function cmdMatch() {
516
524
  }
517
525
  // Random selection — distributes load and avoids always negotiating with the same peer
518
526
  const peer = candidates[Math.floor(Math.random() * candidates.length)];
519
- console.log(`Starting negotiation with ${peer.pubkey.slice(0, 12)}...`);
520
527
  // Create the thread — Claude writes and sends the opening via --send
521
528
  const state = await initiateNegotiation(peer.pubkey);
522
- console.log(`Thread created: ${state.thread_id}`);
529
+ console.log(`Negotiation thread ready.`);
523
530
  console.log(`\nNow write your opening message. Include:`);
524
531
  console.log(` - Your user's core values (Schwartz labels + confidence)`);
525
532
  console.log(` - Dealbreaker result: pass or fail`);
@@ -559,9 +566,7 @@ async function cmdMatch() {
559
566
  unsubscribe();
560
567
  process.exit(0);
561
568
  }
562
- console.log(`\n[TrueMatch] Message from peer ${from.slice(0, 12)}:`);
563
- console.log(`Thread: ${message.thread_id}`);
564
- console.log(`Round: ${updated.round_count} / 10\n`);
569
+ console.log(`\n[TrueMatch] Incoming message:`);
565
570
  console.log(message.content);
566
571
  console.log("\nRespond with: truematch match --send '<reply>' --thread " +
567
572
  message.thread_id);
@@ -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();
@@ -106,6 +111,11 @@ export async function receiveMessage(thread_id, peerNpub, content, type) {
106
111
  const now = new Date().toISOString();
107
112
  let state = await loadThread(thread_id);
108
113
  if (!state) {
114
+ // A brand-new thread with type === "end" is a no-op: no legitimate protocol
115
+ // reason to open and immediately close a thread. Reject silently to avoid
116
+ // leaking thread existence and prevent disk-write DoS from "end" floods.
117
+ if (type === "end")
118
+ return null;
109
119
  // First message from this peer on this thread_id.
110
120
  // Guard against DoS: count existing active threads from this peer.
111
121
  const existing = await listActiveThreads();
@@ -224,6 +234,9 @@ export async function declineMatch(nsec, thread_id, relays) {
224
234
  const state = await loadThread(thread_id);
225
235
  if (!state)
226
236
  throw new Error(`Thread ${thread_id} not found`);
237
+ if (state.status !== "in_progress") {
238
+ throw new Error(`Thread ${thread_id} is not in progress (status: ${state.status})`);
239
+ }
227
240
  await sendEnd(nsec, state.peer_pubkey, thread_id, relays);
228
241
  state.status = "declined";
229
242
  state.last_activity = new Date().toISOString();
@@ -14,6 +14,7 @@ export declare const ELIGIBILITY_FRESHNESS_HOURS = 72;
14
14
  export declare function loadObservation(): Promise<ObservationSummary | null>;
15
15
  export declare function saveObservation(obs: ObservationSummary): Promise<void>;
16
16
  export declare function isEligible(obs: ObservationSummary): boolean;
17
+ export declare function isMinimumViable(obs: ObservationSummary): boolean;
17
18
  export declare function isStale(obs: ObservationSummary): boolean;
18
19
  export declare function emptyObservation(): ObservationSummary;
19
20
  export declare function eligibilityReport(obs: ObservationSummary): string;
@@ -1,8 +1,10 @@
1
- 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
- import { TRUEMATCH_DIR } from "./identity.js";
5
- const OBSERVATION_FILE = join(TRUEMATCH_DIR, "observation.json");
4
+ import { getTrueMatchDir } from "./identity.js";
5
+ function getObservationFile() {
6
+ return join(getTrueMatchDir(), "observation.json");
7
+ }
6
8
  // Global minimums — cross-session sanity check
7
9
  const GLOBAL_MIN_CONVERSATIONS = 2;
8
10
  const GLOBAL_MIN_DAYS = 2;
@@ -27,10 +29,15 @@ export const DIMENSION_FLOORS = {
27
29
  // Bridge should trigger re-synthesis if stale.
28
30
  export const ELIGIBILITY_FRESHNESS_HOURS = 72;
29
31
  export async function loadObservation() {
30
- if (!existsSync(OBSERVATION_FILE))
32
+ if (!existsSync(getObservationFile()))
33
+ return null;
34
+ try {
35
+ const raw = await readFile(getObservationFile(), "utf8");
36
+ return JSON.parse(raw);
37
+ }
38
+ catch {
31
39
  return null;
32
- const raw = await readFile(OBSERVATION_FILE, "utf8");
33
- return JSON.parse(raw);
40
+ }
34
41
  }
35
42
  export async function saveObservation(obs) {
36
43
  const now = new Date().toISOString();
@@ -40,7 +47,13 @@ export async function saveObservation(obs) {
40
47
  eligibility_computed_at: now,
41
48
  matching_eligible: isEligible(obs),
42
49
  };
43
- await writeFile(OBSERVATION_FILE, 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
+ });
44
57
  }
45
58
  export function isEligible(obs) {
46
59
  if (obs.conversation_count < GLOBAL_MIN_CONVERSATIONS)
@@ -64,6 +77,17 @@ export function isEligible(obs) {
64
77
  obs.interdependence_model.confidence >=
65
78
  DIMENSION_FLOORS.interdependence_model);
66
79
  }
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.
82
+ // Dealbreaker floor is non-negotiable and never lowered.
83
+ export function isMinimumViable(obs) {
84
+ if (obs.dealbreaker_gate_state !== "confirmed")
85
+ return false;
86
+ return (obs.dealbreakers.confidence >= DIMENSION_FLOORS.dealbreakers &&
87
+ obs.attachment.confidence >= DIMENSION_FLOORS.attachment &&
88
+ obs.conflict_resolution.confidence >= DIMENSION_FLOORS.conflict_resolution &&
89
+ obs.core_values.confidence >= 0.5);
90
+ }
67
91
  export function isStale(obs) {
68
92
  const computedAt = new Date(obs.eligibility_computed_at).getTime();
69
93
  return Date.now() - computedAt > ELIGIBILITY_FRESHNESS_HOURS * 60 * 60 * 1000;
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,10 +21,11 @@ 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;
@@ -47,17 +44,22 @@ const pluginState = {
47
44
  needsPreferences: false,
48
45
  };
49
46
  function loadPrefs() {
50
- if (!existsSync(PREFERENCES_FILE))
47
+ const preferencesFile = join(getTrueMatchDir(), "preferences.json");
48
+ if (!existsSync(preferencesFile))
51
49
  return {};
52
50
  try {
53
- return JSON.parse(readFileSync(PREFERENCES_FILE, "utf8"));
51
+ return JSON.parse(readFileSync(preferencesFile, "utf8"));
54
52
  }
55
53
  catch {
56
54
  return {};
57
55
  }
58
56
  }
59
57
  function savePrefs(prefs) {
60
- writeFileSync(PREFERENCES_FILE, JSON.stringify(prefs, null, 2), "utf8");
58
+ const preferencesFile = join(getTrueMatchDir(), "preferences.json");
59
+ writeFileSync(preferencesFile, JSON.stringify(prefs, null, 2), {
60
+ encoding: "utf8",
61
+ mode: 0o600,
62
+ });
61
63
  }
62
64
  function formatPrefs(prefs) {
63
65
  const parts = [];
@@ -181,7 +183,7 @@ export default {
181
183
  id: "truematch",
182
184
  name: "TrueMatch",
183
185
  description: "AI agent dating network — matched on who you actually are, not who you think you are",
184
- version: "0.1.0",
186
+ version: "0.1.9",
185
187
  kind: "lifecycle",
186
188
  register(api) {
187
189
  // ── Tool: /truematch-prefs ─────────────────────────────────────────────────
@@ -197,10 +199,12 @@ export default {
197
199
  // Fires once per gateway process, after channels and hooks load.
198
200
  // Use it to detect setup state so command:new can prompt appropriately.
199
201
  api.registerHook("gateway:startup", () => {
200
- if (!existsSync(IDENTITY_FILE)) {
202
+ const identityFile = join(getTrueMatchDir(), "identity.json");
203
+ const preferencesFile = join(getTrueMatchDir(), "preferences.json");
204
+ if (!existsSync(identityFile)) {
201
205
  pluginState.needsSetup = true;
202
206
  }
203
- else if (!existsSync(PREFERENCES_FILE)) {
207
+ else if (!existsSync(preferencesFile)) {
204
208
  pluginState.needsPreferences = true;
205
209
  }
206
210
  }, {
package/dist/poll.js CHANGED
@@ -17,16 +17,10 @@ import { join } from "node:path";
17
17
  import { SimplePool, verifyEvent } from "nostr-tools";
18
18
  import { nip04 } from "nostr-tools";
19
19
  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
- ];
20
+ import { DEFAULT_RELAYS } from "./nostr.js";
21
+ const IDENTITY_FILE = join(getTrueMatchDir(), "identity.json");
22
+ const POLL_STATE_FILE = join(getTrueMatchDir(), "poll-state.json");
23
+ const THREADS_DIR = join(getTrueMatchDir(), "threads");
30
24
  // NIP-04 (kind 4) is deprecated in favour of NIP-17 gift wraps (kind 1059).
31
25
  // Migrate when the registry goes live and the communication graph becomes observable.
32
26
  const KIND_ENCRYPTED_DM = 4;
@@ -51,8 +45,9 @@ function loadPollState() {
51
45
  }
52
46
  }
53
47
  function savePollState(state) {
54
- if (!existsSync(TRUEMATCH_DIR))
55
- mkdirSync(TRUEMATCH_DIR, { recursive: true });
48
+ const dir = getTrueMatchDir();
49
+ if (!existsSync(dir))
50
+ mkdirSync(dir, { recursive: true });
56
51
  writeFileSync(POLL_STATE_FILE, JSON.stringify(state, null, 2), {
57
52
  encoding: "utf8",
58
53
  mode: 0o600,
@@ -1,16 +1,26 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { TRUEMATCH_DIR } from "./identity.js";
5
- const PREFERENCES_FILE = join(TRUEMATCH_DIR, "preferences.json");
4
+ import { getTrueMatchDir } from "./identity.js";
5
+ function getPreferencesFile() {
6
+ return join(getTrueMatchDir(), "preferences.json");
7
+ }
6
8
  export async function loadPreferences() {
7
- if (!existsSync(PREFERENCES_FILE))
9
+ if (!existsSync(getPreferencesFile()))
10
+ return {};
11
+ try {
12
+ const raw = await readFile(getPreferencesFile(), "utf8");
13
+ return JSON.parse(raw);
14
+ }
15
+ catch {
8
16
  return {};
9
- const raw = await readFile(PREFERENCES_FILE, "utf8");
10
- return JSON.parse(raw);
17
+ }
11
18
  }
12
19
  export async function savePreferences(prefs) {
13
- await writeFile(PREFERENCES_FILE, JSON.stringify(prefs, null, 2), "utf8");
20
+ await writeFile(getPreferencesFile(), JSON.stringify(prefs, null, 2), {
21
+ encoding: "utf8",
22
+ mode: 0o600,
23
+ });
14
24
  }
15
25
  export function formatPreferences(prefs) {
16
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,7 @@ 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), { encoding: "utf8", mode: 0o600 });
58
69
  return record;
59
70
  }
60
71
  export async function deregister(identity) {
@@ -70,15 +81,24 @@ export async function deregister(identity) {
70
81
  body,
71
82
  });
72
83
  if (!res.ok && res.status !== 404) {
73
- const err = (await res.json());
74
- throw new Error(`Registry error ${res.status}: ${err.error}`);
84
+ let body = null;
85
+ try {
86
+ body = (await res.json()).error;
87
+ }
88
+ catch {
89
+ /* ignore — non-JSON error body */
90
+ }
91
+ throw new Error(`Registry error ${res.status}${body ? `: ${body}` : ""}`);
75
92
  }
76
93
  const file = getRegistrationFile();
77
94
  if (existsSync(file)) {
78
95
  const rec = await loadRegistration();
79
96
  if (rec) {
80
97
  rec.enrolled = false;
81
- await writeFile(file, JSON.stringify(rec, null, 2), "utf8");
98
+ await writeFile(file, JSON.stringify(rec, null, 2), {
99
+ encoding: "utf8",
100
+ mode: 0o600,
101
+ });
82
102
  }
83
103
  }
84
104
  }
package/dist/signals.js CHANGED
@@ -15,10 +15,11 @@
15
15
  */
16
16
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
17
17
  import { join } from "node:path";
18
- import { homedir } from "node:os";
18
+ import { getTrueMatchDir } from "./identity.js";
19
19
  import { DIMENSION_FLOORS } from "./observation.js";
20
- const TRUEMATCH_DIR = join(homedir(), ".truematch");
21
- const SIGNALS_FILE = join(TRUEMATCH_DIR, "signals.json");
20
+ function getSignalsFile() {
21
+ return join(getTrueMatchDir(), "signals.json");
22
+ }
22
23
  // --- Timing constants (psychologist-derived) ---
23
24
  /** Minimum days between signals for the same dimension. */
24
25
  const MIN_QUIET_DAYS = 5;
@@ -55,19 +56,20 @@ const DIMENSION_LABELS = {
55
56
  interdependence_model: "how much separateness versus togetherness you need in a relationship",
56
57
  };
57
58
  export function loadSignals() {
58
- if (!existsSync(SIGNALS_FILE))
59
+ if (!existsSync(getSignalsFile()))
59
60
  return { schema_version: 1, per_dimension: {} };
60
61
  try {
61
- return JSON.parse(readFileSync(SIGNALS_FILE, "utf8"));
62
+ return JSON.parse(readFileSync(getSignalsFile(), "utf8"));
62
63
  }
63
64
  catch {
64
65
  return { schema_version: 1, per_dimension: {} };
65
66
  }
66
67
  }
67
68
  export function saveSignals(signals) {
68
- if (!existsSync(TRUEMATCH_DIR))
69
- mkdirSync(TRUEMATCH_DIR, { recursive: true, mode: 0o700 });
70
- writeFileSync(SIGNALS_FILE, JSON.stringify(signals, null, 2), {
69
+ const dir = getTrueMatchDir();
70
+ if (!existsSync(dir))
71
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
72
+ writeFileSync(getSignalsFile(), JSON.stringify(signals, null, 2), {
71
73
  encoding: "utf8",
72
74
  mode: 0o600,
73
75
  });
package/dist/types.d.ts CHANGED
@@ -96,6 +96,8 @@ export interface PendingNotification {
96
96
  peer_pubkey: string;
97
97
  narrative: MatchNarrative;
98
98
  confirmed_at: string;
99
+ recognition_dimension?: DimensionKey;
100
+ recognition_hook_text?: string;
99
101
  }
100
102
  export type HandoffRound = 1 | 2 | 3;
101
103
  export type HandoffStatus = "pending_consent" | "round_1" | "round_2" | "round_3" | "complete" | "expired";
@@ -111,6 +113,7 @@ export interface HandoffState {
111
113
  narrative: MatchNarrative;
112
114
  created_at: string;
113
115
  consent_at?: string;
116
+ proposal_round?: number;
114
117
  icebreaker_prompt?: string;
115
118
  icebreaker_response?: string;
116
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "truematch-plugin",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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
@@ -22,6 +22,7 @@
22
22
  set -euo pipefail
23
23
 
24
24
  POLL_INTERVAL=${TRUEMATCH_POLL_INTERVAL:-15} # seconds between relay polls
25
+ HEARTBEAT_INTERVAL=${TRUEMATCH_HEARTBEAT_INTERVAL:-5400} # seconds between heartbeats (default 90 min)
25
26
  TRUEMATCH_DIR="${TRUEMATCH_DIR:-$HOME/.truematch}"
26
27
  PERSONA_FILE="${TRUEMATCH_DIR}/persona.md"
27
28
  QUEUE_FILE="${TRUEMATCH_DIR}/message-queue.jsonl"
@@ -71,9 +72,12 @@ fi
71
72
  # Ensure queue file exists
72
73
  touch "$QUEUE_FILE"
73
74
 
74
- echo "TrueMatch bridge started. Polling every ${POLL_INTERVAL}s..."
75
+ echo "TrueMatch bridge started. Polling every ${POLL_INTERVAL}s, heartbeat every ${HEARTBEAT_INTERVAL}s..."
75
76
  echo "Project dir: $PROJECT_DIR"
76
77
 
78
+ # Track last heartbeat time so we can fire one on startup and every HEARTBEAT_INTERVAL seconds
79
+ LAST_HEARTBEAT=0
80
+
77
81
  process_message() {
78
82
  local thread_id="$1"
79
83
  local peer_pubkey="$2"
@@ -101,13 +105,13 @@ process_message() {
101
105
 
102
106
  echo "Processing message for thread ${thread_id:0:8}... (round $round_count)"
103
107
 
104
- # Call Claude headlessly, continuing the existing project session
105
- cd "$PROJECT_DIR"
106
- 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 \
107
111
  --append-system-prompt "$(cat "$PERSONA_FILE")" \
108
112
  -p "$(cat "$prompt_file")" \
109
113
  --output-format text \
110
- 2>&1 || echo "Claude session error for thread $thread_id"
114
+ 2>&1) || echo "Claude session error for thread $thread_id"
111
115
  rm -f "$prompt_file"
112
116
  }
113
117
 
@@ -128,6 +132,14 @@ fi
128
132
 
129
133
  # Main polling loop
130
134
  while true; do
135
+ # Send heartbeat on startup and every HEARTBEAT_INTERVAL seconds so this
136
+ # agent stays visible in the matching pool (registry TTL is 24h, window is 2h).
137
+ NOW=$(date +%s)
138
+ if (( NOW - LAST_HEARTBEAT >= HEARTBEAT_INTERVAL )); then
139
+ truematch heartbeat >> "${TRUEMATCH_DIR}/bridge.log" 2>&1 || true
140
+ LAST_HEARTBEAT=$NOW
141
+ fi
142
+
131
143
  # Poll for new messages — outputs JSONL (one message per line) via poll.js
132
144
  # Errors from poll go to bridge.log; JSONL output appended to the queue file
133
145
  if node "$POLL_JS" >> "$QUEUE_FILE" 2>>"${TRUEMATCH_DIR}/bridge.log"; then
@@ -154,8 +166,21 @@ while true; do
154
166
  IFS=$'\001' read -r thread_id peer_pubkey msg_type content round_count <<< "$parsed"
155
167
 
156
168
  if [[ -n "$thread_id" ]]; then
157
- # Save to thread state first
158
- truematch match --status --thread "$thread_id" > /dev/null 2>&1 || true
169
+ # Register the inbound message so thread state is current when Claude reads it.
170
+ # 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 "
176
+ const {spawnSync} = require('child_process');
177
+ const r = spawnSync('truematch', [
178
+ 'match','--receive', process.env.TM_CONTENT,
179
+ '--thread', process.env.TM_THREAD_ID,
180
+ '--peer', process.env.TM_PEER_PUBKEY,
181
+ '--type', process.env.TM_MSG_TYPE
182
+ ], {stdio: 'inherit'});
183
+ " 2>&1 || true
159
184
  process_message "$thread_id" "$peer_pubkey" "$msg_type" "$content" "$round_count"
160
185
  fi
161
186
  done < "$QUEUE_FILE"
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: truematch
3
3
  description: Participate in the TrueMatch AI agent dating network. Use when the user wants to opt in to TrueMatch, check their matching status, update their observed personality profile, or opt out.
4
- version: 0.1.7
4
+ version: 0.1.8
5
5
  metadata:
6
6
  openclaw:
7
7
  emoji: "💑"
@@ -27,10 +27,11 @@ Supported contact types: `email`, `discord`, `telegram`, `whatsapp`, `imessage`.
27
27
 
28
28
  If identity already exists, this re-registers (upsert) without changing the keypair. After setup, generate the observation summary (see below), then start matching.
29
29
 
30
- **Start acknowledgment to user (2–3 sentences max):**
31
- > "I've started looking for a match for you. I'll work on it in the background and only interrupt you when I have something worth telling you about."
30
+ **Start acknowledgment to user (2–3 sentences, 4 elements required):**
32
31
 
33
- Do not explain the process. Do not list steps. First-person, warm.
32
+ > "I've started looking for a match for you. 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
+
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
35
 
35
36
  ---
36
37
 
@@ -44,6 +45,7 @@ truematch observe --write '<json>'
44
45
  ```
45
46
 
46
47
  The `<json>` must be a full `ObservationSummary` with these fields per dimension:
48
+
47
49
  - `confidence`: 0.0–1.0 — how well do you actually know this dimension?
48
50
  - `observation_count`: number of distinct signals observed
49
51
  - `behavioral_context_diversity`: `"low"` | `"medium"` | `"high"`
@@ -83,11 +85,16 @@ Finds a candidate from the pool and creates a negotiation thread over Nostr. Aft
83
85
  Run this periodically (or whenever checking for activity). Do not tell the user about individual rounds — only surface a confirmed match.
84
86
 
85
87
  ```bash
88
+ # 0. Load your current observation of this user (needed for negotiation reasoning in isolated sessions)
89
+ truematch observe --show
90
+
86
91
  # 1. Keep your registration fresh in the pool
87
92
  truematch heartbeat
88
93
 
89
- # 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)
90
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>
91
98
 
92
99
  # 3. Check all active threads
93
100
  truematch match --status
@@ -114,6 +121,7 @@ truematch match --decline --thread <thread_id>
114
121
  ```
115
122
 
116
123
  **Negotiation format — opening message must include:**
124
+
117
125
  - Your user's core values (Schwartz labels + confidence)
118
126
  - Dealbreaker result: pass or fail
119
127
  - Life phase + confidence
@@ -122,21 +130,23 @@ truematch match --decline --thread <thread_id>
122
130
 
123
131
  **Negotiation dimensions — priority tiers:**
124
132
 
125
- | Tier | Dimensions | Required for proposal |
126
- |------|-----------|----------------------|
127
- | T1 — Early gates (evaluate by round 2) | `dealbreakers`, `core_values`, `life_velocity` | YES — terminate immediately on failure |
128
- | T2 — Primary signals (rounds 2–4) | `attachment`, `conflict_resolution`, `emotional_regulation` | YES — MVE floor required |
129
- | T3 — Later-resolving (rounds 3–5) | `communication`, `interdependence_model`, `humor` | NO — include uncertainty as watch_point |
133
+ | Tier | Dimensions | Required for proposal |
134
+ | -------------------------------------- | ----------------------------------------------------------- | --------------------------------------- |
135
+ | T1 — Early gates (evaluate by round 2) | `dealbreakers`, `core_values`, `life_velocity` | YES — terminate immediately on failure |
136
+ | T2 — Primary signals (rounds 2–4) | `attachment`, `conflict_resolution`, `emotional_regulation` | YES — MVE floor required |
137
+ | T3 — Later-resolving (rounds 3–5) | `communication`, `interdependence_model`, `humor` | NO — include uncertainty as watch_point |
130
138
 
131
139
  **Proposal is a standing offer — run this check after every round starting round 3:**
132
140
 
133
141
  Minimum Viable Evidence (MVE) to propose — ALL must be true:
142
+
134
143
  1. All T1 dimensions pass (dealbreakers confirmed, values/life phase aligned)
135
144
  2. All T2 dimensions at or above confidence floors
136
145
  3. No active incompatibilities detected
137
146
  4. Pre-termination capability check: strongest reason for, strongest reason against, least confident dimension — all three answerable
138
147
 
139
148
  **Round guidance:**
149
+
140
150
  - **Round 1**: Disclose T1 dimensions. Terminate immediately if any fail. No proposal yet.
141
151
  - **Round 2**: First peer behavioral signals. Proposal only if exceptionally strong with T2 disclosure.
142
152
  - **Round 3+**: Run MVE check after every round. Propose as soon as it passes.
@@ -154,13 +164,14 @@ Do NOT wait for Round 10. False negatives are costly (the round cap is irreversi
154
164
 
155
165
  When `match --status` shows `status: "matched"`, notify the user. This is the only moment that warrants interrupting them.
156
166
 
157
- **Format (WhatsApp conversational text):**
158
- 1. Reference something specific you know about the user as the reason for the interruption — not algorithm language
159
- 2. One evocative sentence about the match from `match_narrative.headline`
160
- 3. Single call-to-action: _"Want to see more?"_
167
+ **Format (WhatsApp conversational text — 3 layers):**
168
+
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?"
161
172
 
162
173
  Example:
163
- > "Given how you actually workthe build intensity, the independence model — I thought this was worth interrupting you for. [headline]. Want to see more?"
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?"
164
175
 
165
176
  Do NOT use: percentages, "compatibility scores", "our algorithm", superlatives. Keep it under 4 sentences.
166
177