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 +98 -0
- package/dist/handoff.js +13 -0
- package/dist/identity.d.ts +0 -1
- package/dist/identity.js +7 -3
- package/dist/index.js +22 -16
- package/dist/negotiation.js +8 -3
- package/dist/nostr.js +17 -15
- package/dist/observation.js +18 -6
- package/dist/plugin.d.ts +10 -1
- package/dist/plugin.js +49 -26
- package/dist/poll.js +10 -13
- package/dist/preferences.js +11 -3
- package/dist/registry.js +31 -8
- package/openclaw.plugin.json +1 -1
- package/package.json +16 -9
- package/scripts/bridge.sh +13 -9
- package/skills/truematch/SKILL.md +48 -5
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
|
}
|
package/dist/identity.d.ts
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
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
|
|
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)}... —
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(`
|
|
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
|
-
|
|
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]
|
|
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);
|
package/dist/negotiation.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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: () => {
|
package/dist/observation.js
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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 >=
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
const observationFile = join(getTrueMatchDir(), "observation.json");
|
|
25
|
+
if (!existsSync(observationFile))
|
|
29
26
|
return null;
|
|
30
27
|
try {
|
|
31
|
-
return JSON.parse(readFileSync(
|
|
28
|
+
return JSON.parse(readFileSync(observationFile, "utf8"));
|
|
32
29
|
}
|
|
33
30
|
catch {
|
|
34
31
|
return null;
|
|
35
32
|
}
|
|
36
33
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
50
|
+
const preferencesFile = join(getTrueMatchDir(), "preferences.json");
|
|
51
|
+
if (!existsSync(preferencesFile))
|
|
51
52
|
return {};
|
|
52
53
|
try {
|
|
53
|
-
return JSON.parse(readFileSync(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
21
|
-
const IDENTITY_FILE = join(
|
|
22
|
-
const POLL_STATE_FILE = join(
|
|
23
|
-
const THREADS_DIR = join(
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/preferences.js
CHANGED
|
@@ -8,11 +8,19 @@ function getPreferencesFile() {
|
|
|
8
8
|
export async function loadPreferences() {
|
|
9
9
|
if (!existsSync(getPreferencesFile()))
|
|
10
10
|
return {};
|
|
11
|
-
|
|
12
|
-
|
|
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),
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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),
|
|
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
|
-
|
|
74
|
-
|
|
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),
|
|
101
|
+
await writeFile(file, JSON.stringify(rec, null, 2), {
|
|
102
|
+
encoding: "utf8",
|
|
103
|
+
mode: 0o600,
|
|
104
|
+
});
|
|
82
105
|
}
|
|
83
106
|
}
|
|
84
107
|
}
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "truematch-plugin",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"
|
|
50
|
-
|
|
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
|
|
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
|
-
#
|
|
171
|
-
TM_CONTENT="$content"
|
|
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',
|
|
176
|
-
'--peer',
|
|
177
|
-
'--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.
|
|
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
|
|
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
|
|
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
|