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 +98 -0
- package/dist/identity.d.ts +0 -1
- package/dist/identity.js +7 -3
- package/dist/index.js +25 -20
- package/dist/negotiation.js +15 -2
- package/dist/observation.d.ts +1 -0
- package/dist/observation.js +31 -7
- package/dist/plugin.js +17 -13
- package/dist/poll.js +7 -12
- package/dist/preferences.js +16 -6
- package/dist/registry.js +28 -8
- package/dist/signals.js +10 -8
- package/dist/types.d.ts +3 -0
- package/package.json +16 -9
- package/scripts/bridge.sh +32 -7
- package/skills/truematch/SKILL.md +26 -15
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/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
|
@@ -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
|
-
|
|
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
|
|
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)}... —
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(`
|
|
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]
|
|
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);
|
package/dist/negotiation.js
CHANGED
|
@@ -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();
|
|
@@ -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();
|
package/dist/observation.d.ts
CHANGED
|
@@ -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;
|
package/dist/observation.js
CHANGED
|
@@ -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 {
|
|
5
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -47,17 +44,22 @@ const pluginState = {
|
|
|
47
44
|
needsPreferences: false,
|
|
48
45
|
};
|
|
49
46
|
function loadPrefs() {
|
|
50
|
-
|
|
47
|
+
const preferencesFile = join(getTrueMatchDir(), "preferences.json");
|
|
48
|
+
if (!existsSync(preferencesFile))
|
|
51
49
|
return {};
|
|
52
50
|
try {
|
|
53
|
-
return JSON.parse(readFileSync(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
];
|
|
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
|
-
|
|
55
|
-
|
|
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,
|
package/dist/preferences.js
CHANGED
|
@@ -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 {
|
|
5
|
-
|
|
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(
|
|
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
|
-
|
|
10
|
-
return JSON.parse(raw);
|
|
17
|
+
}
|
|
11
18
|
}
|
|
12
19
|
export async function savePreferences(prefs) {
|
|
13
|
-
await writeFile(
|
|
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
|
-
|
|
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,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
|
-
|
|
74
|
-
|
|
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),
|
|
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 {
|
|
18
|
+
import { getTrueMatchDir } from "./identity.js";
|
|
19
19
|
import { DIMENSION_FLOORS } from "./observation.js";
|
|
20
|
-
|
|
21
|
-
|
|
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(
|
|
59
|
+
if (!existsSync(getSignalsFile()))
|
|
59
60
|
return { schema_version: 1, per_dimension: {} };
|
|
60
61
|
try {
|
|
61
|
-
return JSON.parse(readFileSync(
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
@@ -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
|
|
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
|
-
#
|
|
158
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
| T1 — Early gates (evaluate by round 2) | `dealbreakers`, `core_values`, `life_velocity`
|
|
128
|
-
| T2 — Primary signals (rounds 2–4)
|
|
129
|
-
| T3 — Later-resolving (rounds 3–5)
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
> "
|
|
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
|
|