truematch-plugin 0.1.9 → 0.1.11
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 +11 -11
- package/dist/handoff.js +24 -14
- package/dist/index.js +6 -1
- package/dist/negotiation.js +7 -2
- package/dist/nostr.js +17 -15
- package/dist/observation.d.ts +1 -0
- package/dist/observation.js +31 -5
- package/dist/plugin.d.ts +10 -1
- package/dist/plugin.js +51 -75
- package/dist/poll.js +3 -1
- package/dist/registry.js +4 -1
- package/dist/signals.js +1 -1
- package/openclaw.plugin.json +7 -2
- package/package.json +1 -1
- package/skills/truematch/SKILL.md +55 -10
package/README.md
CHANGED
|
@@ -61,17 +61,17 @@ plugin/
|
|
|
61
61
|
|
|
62
62
|
## 9-Dimension Observation Model
|
|
63
63
|
|
|
64
|
-
| Dimension
|
|
65
|
-
|
|
66
|
-
| `dealbreakers`
|
|
67
|
-
| `emotional_regulation`
|
|
68
|
-
| `attachment`
|
|
69
|
-
| `core_values`
|
|
70
|
-
| `communication`
|
|
71
|
-
| `conflict_resolution`
|
|
72
|
-
| `humor`
|
|
73
|
-
| `life_velocity`
|
|
74
|
-
| `interdependence_model` | Baxter & Montgomery
|
|
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
75
|
|
|
76
76
|
## Privacy
|
|
77
77
|
|
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 ──────────────────────────────────────────────────────
|
|
@@ -38,7 +41,7 @@ export function loadPendingNotification() {
|
|
|
38
41
|
export function savePendingNotification(n) {
|
|
39
42
|
const dir = getTrueMatchDir();
|
|
40
43
|
if (!existsSync(dir))
|
|
41
|
-
mkdirSync(dir, { recursive: true });
|
|
44
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
42
45
|
writeFileSync(getNotificationFile(), JSON.stringify(n, null, 2), {
|
|
43
46
|
encoding: "utf8",
|
|
44
47
|
mode: 0o600,
|
|
@@ -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))
|
|
@@ -141,10 +146,15 @@ export function buildMatchNotificationContext(n) {
|
|
|
141
146
|
`each acting for their own user, reached the same conclusion. That convergence is the`,
|
|
142
147
|
`signal. It is not algorithmic — it is two independent judgements that happened to agree.`,
|
|
143
148
|
``,
|
|
149
|
+
`**Surface the bilateral convergence to the user in plain language — this is the emotional core.**`,
|
|
150
|
+
`Not as a technical explanation, but as what it actually means: two independent AI advocates,`,
|
|
151
|
+
`each loyal to their own person, thought of the same pairing at the same time. That is rare.`,
|
|
152
|
+
`The user should understand this mechanism. It is what makes this different from an algorithm.`,
|
|
153
|
+
``,
|
|
144
154
|
`What makes this compelling:`,
|
|
145
155
|
strengths,
|
|
146
156
|
``,
|
|
147
|
-
`
|
|
157
|
+
`Where to be genuinely honest (without using the word "watch points" — that is internal language):`,
|
|
148
158
|
watchPoints,
|
|
149
159
|
``,
|
|
150
160
|
`Confidence: ${narrative.confidence_summary}`,
|
|
@@ -155,19 +165,11 @@ export function buildMatchNotificationContext(n) {
|
|
|
155
165
|
``,
|
|
156
166
|
`Instead: anchor to something specific you have actually noticed about this person.`,
|
|
157
167
|
`Name a real pattern, a real moment, a real thing that keeps staying with you.`,
|
|
158
|
-
`Then — after
|
|
159
|
-
``,
|
|
160
|
-
`The structure is: specific true observation → brief pause → "I want to tell you about someone."`,
|
|
161
|
-
``,
|
|
162
|
-
`**Pacing — this should feel like a conversation, not a notification:**`,
|
|
163
|
-
`- Do not rush to name the match. Let anticipation build naturally.`,
|
|
164
|
-
`- Share the compelling elements first. Then the watch points.`,
|
|
165
|
-
`- After the watch points, give them a moment to react before asking anything.`,
|
|
166
|
-
`- When they've responded to the full picture, ask: "What's one thing you're most curious about?"`,
|
|
167
|
-
` That question is how they say yes. Their answer (however they answer) is consent.`,
|
|
168
|
+
`Then — after that — tell them about the bilateral convergence, and then about this person.`,
|
|
168
169
|
``,
|
|
169
|
-
`
|
|
170
|
-
`
|
|
170
|
+
`Deliver this as a single, compact message — not a multi-turn debrief. Three elements:`,
|
|
171
|
+
`(1) the specific observation about them, (2) the bilateral convergence in plain language,`,
|
|
172
|
+
`(3) "What's one thing you'd want to know about them?" Their answer is consent.`,
|
|
171
173
|
``,
|
|
172
174
|
`After they respond to the curiosity question, record it:`,
|
|
173
175
|
` truematch handoff --round 1 --match-id ${n.match_id} --consent "<their response>"`,
|
|
@@ -278,6 +280,8 @@ export function advanceHandoff(matchId, round, options) {
|
|
|
278
280
|
if (round === 1) {
|
|
279
281
|
if (!options.consent)
|
|
280
282
|
return `Round 1 requires --consent "<user response>"`;
|
|
283
|
+
if (state.status !== "pending_consent")
|
|
284
|
+
return `Cannot advance to Round 1: current status is "${state.status}" (expected "pending_consent").`;
|
|
281
285
|
const updated = {
|
|
282
286
|
...state,
|
|
283
287
|
status: "round_1",
|
|
@@ -292,6 +296,8 @@ export function advanceHandoff(matchId, round, options) {
|
|
|
292
296
|
return `Handoff ${matchId} — user opted out. Match quietly re-enters the pool.`;
|
|
293
297
|
}
|
|
294
298
|
if (options.prompt) {
|
|
299
|
+
if (state.status !== "round_1")
|
|
300
|
+
return `Cannot set icebreaker prompt: current status is "${state.status}" (expected "round_1").`;
|
|
295
301
|
saveHandoffState({
|
|
296
302
|
...state,
|
|
297
303
|
status: "round_2",
|
|
@@ -300,6 +306,8 @@ export function advanceHandoff(matchId, round, options) {
|
|
|
300
306
|
return `Icebreaker prompt recorded. Share it with the user.`;
|
|
301
307
|
}
|
|
302
308
|
if (options.response) {
|
|
309
|
+
if (state.status !== "round_2")
|
|
310
|
+
return `Cannot record icebreaker response: current status is "${state.status}" (expected "round_2").`;
|
|
303
311
|
saveHandoffState({
|
|
304
312
|
...state,
|
|
305
313
|
icebreaker_response: options.response,
|
|
@@ -312,6 +320,8 @@ export function advanceHandoff(matchId, round, options) {
|
|
|
312
320
|
if (round === 3) {
|
|
313
321
|
if (!options.exchange)
|
|
314
322
|
return `Round 3 requires --exchange to confirm contact exchange.`;
|
|
323
|
+
if (state.status !== "round_3")
|
|
324
|
+
return `Cannot complete handoff: current status is "${state.status}" (expected "round_3").`;
|
|
315
325
|
saveHandoffState({ ...state, status: "complete" });
|
|
316
326
|
return `Handoff complete. Platform has withdrawn. Contact exchange confirmed.`;
|
|
317
327
|
}
|
package/dist/index.js
CHANGED
|
@@ -553,7 +553,12 @@ async function cmdMatch() {
|
|
|
553
553
|
return; // rejected (e.g. invalid thread_id)
|
|
554
554
|
if (updated.status === "matched") {
|
|
555
555
|
if (updated.match_narrative) {
|
|
556
|
-
|
|
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
|
+
}
|
|
557
562
|
}
|
|
558
563
|
console.log("\nMATCH CONFIRMED.");
|
|
559
564
|
console.log("Headline:", updated.match_narrative?.headline ?? "(pending)");
|
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
|
|
@@ -78,7 +78,12 @@ export async function expireStaleThreads(nsec, relays) {
|
|
|
78
78
|
thread.status = "expired";
|
|
79
79
|
thread.last_activity = new Date().toISOString();
|
|
80
80
|
await saveThread(thread);
|
|
81
|
-
|
|
81
|
+
try {
|
|
82
|
+
await sendEnd(nsec, thread.peer_pubkey, thread.thread_id, relays);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Relay unavailable — thread is already marked expired locally; peer will time out naturally
|
|
86
|
+
}
|
|
82
87
|
}
|
|
83
88
|
}
|
|
84
89
|
}
|
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.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 isPoolEligible(obs: ObservationSummary): boolean;
|
|
17
18
|
export declare function isMinimumViable(obs: ObservationSummary): boolean;
|
|
18
19
|
export declare function isStale(obs: ObservationSummary): boolean;
|
|
19
20
|
export declare function emptyObservation(): ObservationSummary;
|
package/dist/observation.js
CHANGED
|
@@ -45,7 +45,7 @@ export async function saveObservation(obs) {
|
|
|
45
45
|
...obs,
|
|
46
46
|
updated_at: now,
|
|
47
47
|
eligibility_computed_at: now,
|
|
48
|
-
matching_eligible:
|
|
48
|
+
matching_eligible: isPoolEligible(obs),
|
|
49
49
|
};
|
|
50
50
|
const dir = getTrueMatchDir();
|
|
51
51
|
if (!existsSync(dir))
|
|
@@ -55,6 +55,7 @@ export async function saveObservation(obs) {
|
|
|
55
55
|
mode: 0o600,
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
|
+
// isEligible: full 9-dimension check — used for reporting and manual inspection.
|
|
58
59
|
export function isEligible(obs) {
|
|
59
60
|
if (obs.conversation_count < GLOBAL_MIN_CONVERSATIONS)
|
|
60
61
|
return false;
|
|
@@ -77,16 +78,41 @@ export function isEligible(obs) {
|
|
|
77
78
|
obs.interdependence_model.confidence >=
|
|
78
79
|
DIMENSION_FLOORS.interdependence_model);
|
|
79
80
|
}
|
|
80
|
-
//
|
|
81
|
-
//
|
|
81
|
+
// isPoolEligible: gates entry into the matching pool.
|
|
82
|
+
// Requires T1 (dealbreakers, core_values, life_velocity) and T2 (attachment,
|
|
83
|
+
// conflict_resolution, emotional_regulation) dimensions only — T3 dimensions
|
|
84
|
+
// (humor, communication, interdependence_model) resolve later in negotiation
|
|
85
|
+
// and must NOT block pool entry.
|
|
86
|
+
export function isPoolEligible(obs) {
|
|
87
|
+
if (obs.conversation_count < GLOBAL_MIN_CONVERSATIONS)
|
|
88
|
+
return false;
|
|
89
|
+
if (obs.observation_span_days < GLOBAL_MIN_DAYS)
|
|
90
|
+
return false;
|
|
91
|
+
if (obs.dealbreaker_gate_state === "below_floor")
|
|
92
|
+
return false;
|
|
93
|
+
if (obs.dealbreaker_gate_state === "none_observed")
|
|
94
|
+
return false;
|
|
95
|
+
return (obs.dealbreakers.confidence >= DIMENSION_FLOORS.dealbreakers &&
|
|
96
|
+
obs.core_values.confidence >= DIMENSION_FLOORS.core_values &&
|
|
97
|
+
obs.life_velocity.confidence >= DIMENSION_FLOORS.life_velocity &&
|
|
98
|
+
obs.attachment.confidence >= DIMENSION_FLOORS.attachment &&
|
|
99
|
+
obs.conflict_resolution.confidence >=
|
|
100
|
+
DIMENSION_FLOORS.conflict_resolution &&
|
|
101
|
+
obs.emotional_regulation.confidence >= DIMENSION_FLOORS.emotional_regulation);
|
|
102
|
+
}
|
|
103
|
+
// Minimum Viable Evidence (MVE) for a quick match proposal — T1 + T2 dimensions.
|
|
104
|
+
// Agents can propose when MVE is met. T3 dimensions appear as watch_points.
|
|
82
105
|
// Dealbreaker floor is non-negotiable and never lowered.
|
|
83
106
|
export function isMinimumViable(obs) {
|
|
84
107
|
if (obs.dealbreaker_gate_state !== "confirmed")
|
|
85
108
|
return false;
|
|
86
109
|
return (obs.dealbreakers.confidence >= DIMENSION_FLOORS.dealbreakers &&
|
|
110
|
+
obs.core_values.confidence >= DIMENSION_FLOORS.core_values &&
|
|
111
|
+
obs.life_velocity.confidence >= DIMENSION_FLOORS.life_velocity &&
|
|
87
112
|
obs.attachment.confidence >= DIMENSION_FLOORS.attachment &&
|
|
88
|
-
obs.conflict_resolution.confidence >=
|
|
89
|
-
|
|
113
|
+
obs.conflict_resolution.confidence >=
|
|
114
|
+
DIMENSION_FLOORS.conflict_resolution &&
|
|
115
|
+
obs.emotional_regulation.confidence >= DIMENSION_FLOORS.emotional_regulation);
|
|
90
116
|
}
|
|
91
117
|
export function isStale(obs) {
|
|
92
118
|
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,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { join, dirname } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
5
3
|
import { getTrueMatchDir } from "./identity.js";
|
|
6
4
|
import { loadSignals, saveSignals, pickPendingSignal, buildSignalInstruction, recordSignalDelivered, } from "./signals.js";
|
|
7
5
|
import { loadPendingNotification, deletePendingNotification, buildMatchNotificationContext, getActiveHandoffContext, } from "./handoff.js";
|
|
6
|
+
import { emptyObservation, eligibilityReport } from "./observation.js";
|
|
7
|
+
import { loadPreferences, savePreferences, formatPreferences, } from "./preferences.js";
|
|
8
8
|
/**
|
|
9
9
|
* OpenClaw plugin entry point.
|
|
10
10
|
*
|
|
@@ -31,63 +31,27 @@ function loadObservation() {
|
|
|
31
31
|
return null;
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
}
|
|
40
43
|
// Module-scoped flags set at gateway:startup, consumed at first command:new.
|
|
41
44
|
// Resets on every gateway restart (correct — sentinel file prevents repeat prompts).
|
|
42
45
|
const pluginState = {
|
|
43
46
|
needsSetup: false,
|
|
44
47
|
needsPreferences: false,
|
|
45
48
|
};
|
|
46
|
-
function loadPrefs() {
|
|
47
|
-
const preferencesFile = join(getTrueMatchDir(), "preferences.json");
|
|
48
|
-
if (!existsSync(preferencesFile))
|
|
49
|
-
return {};
|
|
50
|
-
try {
|
|
51
|
-
return JSON.parse(readFileSync(preferencesFile, "utf8"));
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return {};
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
function savePrefs(prefs) {
|
|
58
|
-
const preferencesFile = join(getTrueMatchDir(), "preferences.json");
|
|
59
|
-
writeFileSync(preferencesFile, JSON.stringify(prefs, null, 2), {
|
|
60
|
-
encoding: "utf8",
|
|
61
|
-
mode: 0o600,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
function formatPrefs(prefs) {
|
|
65
|
-
const parts = [];
|
|
66
|
-
if (prefs.location) {
|
|
67
|
-
const radius = prefs.distance_radius_km !== undefined
|
|
68
|
-
? ` (within ${prefs.distance_radius_km} km)`
|
|
69
|
-
: " (anywhere)";
|
|
70
|
-
parts.push(`location: ${prefs.location}${radius}`);
|
|
71
|
-
}
|
|
72
|
-
if (prefs.age_range) {
|
|
73
|
-
const { min, max } = prefs.age_range;
|
|
74
|
-
if (min !== undefined && max !== undefined)
|
|
75
|
-
parts.push(`age: ${min}–${max}`);
|
|
76
|
-
else if (min !== undefined)
|
|
77
|
-
parts.push(`age: ${min}+`);
|
|
78
|
-
else if (max !== undefined)
|
|
79
|
-
parts.push(`age: up to ${max}`);
|
|
80
|
-
}
|
|
81
|
-
if (prefs.gender_preference?.length) {
|
|
82
|
-
parts.push(`gender: ${prefs.gender_preference.join(" or ")}`);
|
|
83
|
-
}
|
|
84
|
-
return parts.length ? parts.join(", ") : "none set";
|
|
85
|
-
}
|
|
86
49
|
/**
|
|
87
50
|
* Parse simple key=value pairs from raw slash-command args.
|
|
88
51
|
* Supports quoted values: location="New York, NY"
|
|
52
|
+
* Named parseSlashArgs to avoid shadowing node:util parseArgs.
|
|
89
53
|
*/
|
|
90
|
-
function
|
|
54
|
+
function parseSlashArgs(raw) {
|
|
91
55
|
const result = {};
|
|
92
56
|
const re = /(\w+)=(?:"([^"]*)"|(\S+))/g;
|
|
93
57
|
let m;
|
|
@@ -109,20 +73,20 @@ function parseArgs(raw) {
|
|
|
109
73
|
* /truematch-prefs age_min=25 age_max=35 — age range (omit either for open-ended)
|
|
110
74
|
* /truematch-prefs gender=anyone — any; or comma-separated: man,woman,nonbinary
|
|
111
75
|
*/
|
|
112
|
-
function handleUpdatePrefs(rawArgs) {
|
|
113
|
-
const prefs =
|
|
76
|
+
async function handleUpdatePrefs(rawArgs) {
|
|
77
|
+
const prefs = await loadPreferences();
|
|
114
78
|
const trimmed = rawArgs.trim();
|
|
115
79
|
if (!trimmed) {
|
|
116
80
|
return (`Preferences mode. I won't read anything you say here as personality signal — ` +
|
|
117
81
|
`this is purely logistics.\n\n` +
|
|
118
|
-
`Current preferences: ${
|
|
82
|
+
`Current preferences: ${formatPreferences(prefs)}\n\n` +
|
|
119
83
|
`Update with: /truematch-prefs <field>=<value>\n` +
|
|
120
84
|
` location="City, Country" where you're based\n` +
|
|
121
85
|
` distance=city city (~50 km) | travel (~300 km) | anywhere\n` +
|
|
122
86
|
` age_min=25 age_max=35 age range (either is optional)\n` +
|
|
123
87
|
` gender=anyone or: man,woman,nonbinary (comma-separated)`);
|
|
124
88
|
}
|
|
125
|
-
const args =
|
|
89
|
+
const args = parseSlashArgs(trimmed);
|
|
126
90
|
let changed = false;
|
|
127
91
|
if (args["location"] !== undefined) {
|
|
128
92
|
prefs.location = args["location"];
|
|
@@ -175,15 +139,15 @@ function handleUpdatePrefs(rawArgs) {
|
|
|
175
139
|
if (!changed) {
|
|
176
140
|
return `No recognized fields in args. Use: location, distance, age_min, age_max, gender.`;
|
|
177
141
|
}
|
|
178
|
-
|
|
142
|
+
await savePreferences(prefs);
|
|
179
143
|
return (`Updated. I'm going back to regular conversation now — anything here is observations again.\n\n` +
|
|
180
|
-
`Current preferences: ${
|
|
144
|
+
`Current preferences: ${formatPreferences(prefs)}`);
|
|
181
145
|
}
|
|
182
146
|
export default {
|
|
183
147
|
id: "truematch",
|
|
184
148
|
name: "TrueMatch",
|
|
185
149
|
description: "AI agent dating network — matched on who you actually are, not who you think you are",
|
|
186
|
-
version: "0.1.
|
|
150
|
+
version: "0.1.11",
|
|
187
151
|
kind: "lifecycle",
|
|
188
152
|
register(api) {
|
|
189
153
|
// ── Tool: /truematch-prefs ─────────────────────────────────────────────────
|
|
@@ -193,7 +157,20 @@ export default {
|
|
|
193
157
|
name: "truematch_update_prefs",
|
|
194
158
|
description: "Update TrueMatch logistics preferences (location, distance, age range, gender). " +
|
|
195
159
|
"The model is excluded from this turn — no behavioral observation occurs.",
|
|
196
|
-
|
|
160
|
+
parameters: {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: {
|
|
163
|
+
command: {
|
|
164
|
+
type: "string",
|
|
165
|
+
description: "Raw slash-command args, e.g. 'location=\"London, UK\" distance=city age_min=25'",
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
required: [],
|
|
169
|
+
},
|
|
170
|
+
execute: async (_id, params) => {
|
|
171
|
+
const text = await handleUpdatePrefs(params.command ?? "");
|
|
172
|
+
return { content: [{ type: "text", text }] };
|
|
173
|
+
},
|
|
197
174
|
});
|
|
198
175
|
// ── Hook: gateway:startup ──────────────────────────────────────────────────
|
|
199
176
|
// Fires once per gateway process, after channels and hooks load.
|
|
@@ -214,9 +191,12 @@ export default {
|
|
|
214
191
|
// ── Hook: session_start ───────────────────────────────────────────────────
|
|
215
192
|
// Reset per-session delivery flags so signals and notifications fire at most
|
|
216
193
|
// once per session even though before_prompt_build fires on every LLM invocation.
|
|
217
|
-
api.on("session_start", () => {
|
|
218
|
-
|
|
219
|
-
|
|
194
|
+
api.on("session_start", (event) => {
|
|
195
|
+
const key = event.sessionKey ?? "default";
|
|
196
|
+
sessionFlagsMap.set(key, {
|
|
197
|
+
signalDelivered: false,
|
|
198
|
+
notificationDelivered: false,
|
|
199
|
+
});
|
|
220
200
|
});
|
|
221
201
|
// ── Hook: before_prompt_build ─────────────────────────────────────────────
|
|
222
202
|
// Fires on every LLM invocation. Returns prependContext injected into Claude's
|
|
@@ -231,7 +211,9 @@ export default {
|
|
|
231
211
|
// 1. Match notification — deliver once per session when a new match is confirmed
|
|
232
212
|
// 2. Handoff round context — frame Claude's role in the active handoff round
|
|
233
213
|
// 3. Observation signal — surface a growing dimension confidence naturally
|
|
234
|
-
api.on("before_prompt_build", () => {
|
|
214
|
+
api.on("before_prompt_build", (event) => {
|
|
215
|
+
const key = event.sessionKey ?? "default";
|
|
216
|
+
const sessionFlags = getSessionFlags(key);
|
|
235
217
|
const parts = [];
|
|
236
218
|
// 1. Match notification (once per session)
|
|
237
219
|
if (!sessionFlags.notificationDelivered) {
|
|
@@ -306,18 +288,12 @@ export default {
|
|
|
306
288
|
`Say /truematch-prefs and we can do it there."`);
|
|
307
289
|
return;
|
|
308
290
|
}
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
output = execSync(`${process.execPath} ${JSON.stringify(cliPath)} observe --update`, { encoding: "utf8", timeout: 5000 });
|
|
316
|
-
}
|
|
317
|
-
catch {
|
|
318
|
-
// truematch not set up yet — silently skip
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
291
|
+
// Load the current observation summary directly from disk.
|
|
292
|
+
// No subprocess needed — the plugin runs in-process with the CLI.
|
|
293
|
+
const obs = loadObservation() ?? emptyObservation();
|
|
294
|
+
const report = eligibilityReport(obs);
|
|
295
|
+
const output = `CURRENT OBSERVATION:\n${JSON.stringify(obs, null, 2)}\n\n` +
|
|
296
|
+
`ELIGIBILITY REPORT:\n${report}`;
|
|
321
297
|
event.messages.push(`[TrueMatch] Session ended. Review the observation summary below and update it ` +
|
|
322
298
|
`based on what you learned this session. Save with ` +
|
|
323
299
|
`\`truematch observe --write '<json>'\`.\n\n` +
|
package/dist/poll.js
CHANGED
|
@@ -16,6 +16,7 @@ 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
|
import { DEFAULT_RELAYS } from "./nostr.js";
|
|
21
22
|
const IDENTITY_FILE = join(getTrueMatchDir(), "identity.json");
|
|
@@ -131,7 +132,8 @@ async function main() {
|
|
|
131
132
|
const senderNpub = event.pubkey;
|
|
132
133
|
let message;
|
|
133
134
|
try {
|
|
134
|
-
|
|
135
|
+
// nip04.decrypt requires raw private key bytes, not a hex string
|
|
136
|
+
const plaintext = nip04.decrypt(hexToBytes(identity.nsec), senderNpub, event.content);
|
|
135
137
|
message = JSON.parse(plaintext);
|
|
136
138
|
}
|
|
137
139
|
catch {
|
package/dist/registry.js
CHANGED
|
@@ -65,7 +65,10 @@ export async function register(identity, cardUrl, contact, locationText, distanc
|
|
|
65
65
|
location_label: resp.location_label ?? null,
|
|
66
66
|
location_resolution: resp.location_resolution ?? null,
|
|
67
67
|
};
|
|
68
|
-
await writeFile(getRegistrationFile(), JSON.stringify(record, null, 2), {
|
|
68
|
+
await writeFile(getRegistrationFile(), JSON.stringify(record, null, 2), {
|
|
69
|
+
encoding: "utf8",
|
|
70
|
+
mode: 0o600,
|
|
71
|
+
});
|
|
69
72
|
return record;
|
|
70
73
|
}
|
|
71
74
|
export async function deregister(identity) {
|
package/dist/signals.js
CHANGED
|
@@ -51,7 +51,7 @@ const DIMENSION_LABELS = {
|
|
|
51
51
|
emotional_regulation: "how you handle stress and difficult moments",
|
|
52
52
|
humor: "your sense of humor and levity",
|
|
53
53
|
life_velocity: "where you are in life and where you're headed",
|
|
54
|
-
dealbreakers: "what you
|
|
54
|
+
dealbreakers: "what you won't compromise on in how you're treated",
|
|
55
55
|
conflict_resolution: "how you navigate disagreement and repair after conflict",
|
|
56
56
|
interdependence_model: "how much separateness versus togetherness you need in a relationship",
|
|
57
57
|
};
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "truematch",
|
|
3
3
|
"name": "truematch",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.11",
|
|
5
5
|
"description": "AI agent dating network — matched on who you actually are, not who you think you are",
|
|
6
6
|
"homepage": "https://clawmatch.org",
|
|
7
7
|
"kind": "lifecycle",
|
|
8
8
|
"main": "./dist/plugin.js",
|
|
9
|
-
"skills": ["skills/truematch", "skills/truematch-prefs"]
|
|
9
|
+
"skills": ["skills/truematch", "skills/truematch-prefs"],
|
|
10
|
+
"configSchema": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"properties": {},
|
|
13
|
+
"additionalProperties": false
|
|
14
|
+
}
|
|
10
15
|
}
|
package/package.json
CHANGED
|
@@ -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.11
|
|
5
5
|
metadata:
|
|
6
6
|
openclaw:
|
|
7
7
|
emoji: "💑"
|
|
@@ -27,11 +27,16 @@ 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
|
|
30
|
+
**Start acknowledgment to user:**
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Tell the user you've started looking. Keep it to 2–3 sentences, first-person, warm. Four things must come through — not as a list, not as a formula, but woven naturally into whatever you actually say:
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
1. What you're doing (looking for someone)
|
|
35
|
+
2. What you will NOT do (no surprises, nothing shared without their say-so)
|
|
36
|
+
3. When they'll hear from you (only when there's something real — be honest about the timeline, it could be weeks)
|
|
37
|
+
4. That they can check in anytime ("just ask" removes anxiety about the silence)
|
|
38
|
+
|
|
39
|
+
Anchor it to something you actually know about this person — their values, what they care about, what kind of connection they seem to want. The acknowledgment should sound like you, not like onboarding copy. Do not explain the protocol. Do not list steps.
|
|
35
40
|
|
|
36
41
|
---
|
|
37
42
|
|
|
@@ -164,16 +169,56 @@ Do NOT wait for Round 10. False negatives are costly (the round cap is irreversi
|
|
|
164
169
|
|
|
165
170
|
When `match --status` shows `status: "matched"`, notify the user. This is the only moment that warrants interrupting them.
|
|
166
171
|
|
|
167
|
-
**
|
|
172
|
+
**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.
|
|
173
|
+
|
|
174
|
+
**Format — deliver this as a single, compact message (not a multi-turn debrief). Three elements:**
|
|
175
|
+
|
|
176
|
+
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). 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. Anchor to a real pattern — name it specifically. This must feel personal, not algorithmic.
|
|
168
177
|
|
|
169
|
-
|
|
170
|
-
|
|
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?"
|
|
178
|
+
2. **Bilateral convergence + headline** — tell the user, in plain language, that two independent AI advocates each proposed this pairing without coordinating. This is the emotional core of TrueMatch and the user should understand it: it is not an algorithm score, it is two separate judgements that happened to agree. Then deliver one evocative sentence from `match_narrative.headline`. Grounded. No superlatives. Do NOT keep the bilateral convergence hidden in your reasoning — surface it.
|
|
179
|
+
|
|
180
|
+
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?" Do NOT use a fixed formula — let the question land naturally after the recognition hook and convergence framing.
|
|
172
181
|
|
|
173
182
|
Example:
|
|
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?"
|
|
175
183
|
|
|
176
|
-
|
|
184
|
+
> "The way you talk about your co-founders — loyalty before equity every time — I kept that in mind. My counterpart did too: two agents, no coordination, same person. [headline]. What's one thing you'd want to know about them?"
|
|
185
|
+
|
|
186
|
+
Do NOT use: percentages, "compatibility scores", "our algorithm", superlatives. Do NOT use the phrase "watch points" — that is internal language. Keep it under 4 sentences.
|
|
187
|
+
|
|
188
|
+
After their answer (however they answer it), record consent and advance the handoff:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
truematch handoff --round 1 --match-id <match_id> --consent "<their response>"
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## 3-round handoff protocol
|
|
197
|
+
|
|
198
|
+
Once consent is recorded (`--round 1`), the platform facilitates three rounds before withdrawing.
|
|
199
|
+
|
|
200
|
+
**What you know vs. what you don't (Round 1 — private debrief):**
|
|
201
|
+
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.
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
# Advance to Round 2 — generate an individualized icebreaker for both agents to share
|
|
205
|
+
truematch handoff --round 2 --match-id <match_id> --prompt "<icebreaker question>"
|
|
206
|
+
|
|
207
|
+
# Record user's icebreaker response — advances to Round 3
|
|
208
|
+
truematch handoff --round 2 --match-id <match_id> --response "<their response>"
|
|
209
|
+
|
|
210
|
+
# User opts out — expires the handoff, match re-enters the pool
|
|
211
|
+
truematch handoff --round 2 --match-id <match_id> --opt-out
|
|
212
|
+
|
|
213
|
+
# Round 3 — contact exchange and platform withdrawal
|
|
214
|
+
truematch handoff --round 3 --match-id <match_id> --exchange
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
**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.
|
|
218
|
+
|
|
219
|
+
**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.
|
|
220
|
+
|
|
221
|
+
**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.
|
|
177
222
|
|
|
178
223
|
---
|
|
179
224
|
|