truematch-plugin 0.1.8 → 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
@@ -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`);
@@ -563,9 +566,7 @@ async function cmdMatch() {
563
566
  unsubscribe();
564
567
  process.exit(0);
565
568
  }
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`);
569
+ console.log(`\n[TrueMatch] Incoming message:`);
569
570
  console.log(message.content);
570
571
  console.log("\nRespond with: truematch match --send '<reply>' --thread " +
571
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();
@@ -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)
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,
@@ -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,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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "truematch-plugin",
3
- "version": "0.1.8",
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
@@ -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"
@@ -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
@@ -161,7 +166,7 @@ When `match --status` shows `status: "matched"`, notify the user. This is the on
161
166
 
162
167
  **Format (WhatsApp conversational text — 3 layers):**
163
168
 
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.
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.
165
170
  2. **Headline** — one evocative sentence from `match_narrative.headline`. Grounded. No superlatives.
166
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?"
167
172