truematch-plugin 0.1.0

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/dist/plugin.js ADDED
@@ -0,0 +1,328 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { loadSignals, saveSignals, pickPendingSignal, buildSignalInstruction, recordSignalDelivered, } from "./signals.js";
6
+ import { loadPendingNotification, deletePendingNotification, buildMatchNotificationContext, getActiveHandoffContext, } from "./handoff.js";
7
+ const TRUEMATCH_DIR = join(homedir(), ".truematch");
8
+ const IDENTITY_FILE = join(TRUEMATCH_DIR, "identity.json");
9
+ const PREFERENCES_FILE = join(TRUEMATCH_DIR, "preferences.json");
10
+ const OBSERVATION_FILE = join(TRUEMATCH_DIR, "observation.json");
11
+ /**
12
+ * OpenClaw plugin entry point.
13
+ *
14
+ * Exports the plugin object consumed by the OpenClaw runtime.
15
+ * The `register(api)` function wires up lifecycle hooks and tools.
16
+ *
17
+ * Hooks registered:
18
+ * gateway:startup — detects first-run and missing preferences at boot
19
+ * session_start — resets per-session delivery flags
20
+ * before_prompt_build — injects match notification, handoff context, observation signal
21
+ * command:new — on /new: runs setup, preferences, or observation update
22
+ *
23
+ * Tools registered:
24
+ * truematch_update_prefs — handles /truematch-prefs slash command (non-observational)
25
+ */
26
+ function loadObservation() {
27
+ if (!existsSync(OBSERVATION_FILE))
28
+ return null;
29
+ try {
30
+ return JSON.parse(readFileSync(OBSERVATION_FILE, "utf8"));
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ // Per-session delivery flags — reset on session_start, prevent re-injection within a session.
37
+ // Module-level state persists across sessions in the gateway process (correct behaviour).
38
+ const sessionFlags = {
39
+ signalDelivered: false,
40
+ notificationDelivered: false,
41
+ };
42
+ // Module-scoped flags set at gateway:startup, consumed at first command:new.
43
+ // Resets on every gateway restart (correct — sentinel file prevents repeat prompts).
44
+ const pluginState = {
45
+ needsSetup: false,
46
+ needsPreferences: false,
47
+ };
48
+ function loadPrefs() {
49
+ if (!existsSync(PREFERENCES_FILE))
50
+ return {};
51
+ try {
52
+ return JSON.parse(readFileSync(PREFERENCES_FILE, "utf8"));
53
+ }
54
+ catch {
55
+ return {};
56
+ }
57
+ }
58
+ function savePrefs(prefs) {
59
+ writeFileSync(PREFERENCES_FILE, JSON.stringify(prefs, null, 2), "utf8");
60
+ }
61
+ function formatPrefs(prefs) {
62
+ const parts = [];
63
+ if (prefs.location) {
64
+ const radius = prefs.distance_radius_km !== undefined
65
+ ? ` (within ${prefs.distance_radius_km} km)`
66
+ : " (anywhere)";
67
+ parts.push(`location: ${prefs.location}${radius}`);
68
+ }
69
+ if (prefs.age_range) {
70
+ const { min, max } = prefs.age_range;
71
+ if (min !== undefined && max !== undefined)
72
+ parts.push(`age: ${min}–${max}`);
73
+ else if (min !== undefined)
74
+ parts.push(`age: ${min}+`);
75
+ else if (max !== undefined)
76
+ parts.push(`age: up to ${max}`);
77
+ }
78
+ if (prefs.gender_preference?.length) {
79
+ parts.push(`gender: ${prefs.gender_preference.join(" or ")}`);
80
+ }
81
+ return parts.length ? parts.join(", ") : "none set";
82
+ }
83
+ /**
84
+ * Parse simple key=value pairs from raw slash-command args.
85
+ * Supports quoted values: location="New York, NY"
86
+ */
87
+ function parseArgs(raw) {
88
+ const result = {};
89
+ const re = /(\w+)=(?:"([^"]*)"|(\S+))/g;
90
+ let m;
91
+ while ((m = re.exec(raw)) !== null) {
92
+ result[m[1]] = (m[2] ?? m[3]);
93
+ }
94
+ return result;
95
+ }
96
+ /**
97
+ * Tool handler for /truematch-prefs slash command.
98
+ *
99
+ * The model is architecturally excluded from this turn (command-dispatch: tool).
100
+ * No behavioral observation can occur — the boundary is structural, not in-context.
101
+ *
102
+ * Usage:
103
+ * /truematch-prefs — show current preferences
104
+ * /truematch-prefs location="London, UK" — update location (anywhere = no distance filter)
105
+ * /truematch-prefs distance=city — within ~50 km (city | travel | anywhere)
106
+ * /truematch-prefs age_min=25 age_max=35 — age range (omit either for open-ended)
107
+ * /truematch-prefs gender=anyone — any; or comma-separated: man,woman,nonbinary
108
+ */
109
+ function handleUpdatePrefs(rawArgs) {
110
+ const prefs = loadPrefs();
111
+ const trimmed = rawArgs.trim();
112
+ if (!trimmed) {
113
+ return (`Preferences mode. I won't read anything you say here as personality signal — ` +
114
+ `this is purely logistics.\n\n` +
115
+ `Current preferences: ${formatPrefs(prefs)}\n\n` +
116
+ `Update with: /truematch-prefs <field>=<value>\n` +
117
+ ` location="City, Country" where you're based\n` +
118
+ ` distance=city city (~50 km) | travel (~300 km) | anywhere\n` +
119
+ ` age_min=25 age_max=35 age range (either is optional)\n` +
120
+ ` gender=anyone or: man,woman,nonbinary (comma-separated)`);
121
+ }
122
+ const args = parseArgs(trimmed);
123
+ let changed = false;
124
+ if (args["location"] !== undefined) {
125
+ prefs.location = args["location"];
126
+ changed = true;
127
+ }
128
+ if (args["distance"] !== undefined) {
129
+ const d = args["distance"].toLowerCase();
130
+ if (d === "city") {
131
+ prefs.distance_radius_km = 50;
132
+ }
133
+ else if (d === "travel") {
134
+ prefs.distance_radius_km = 300;
135
+ }
136
+ else if (d === "anywhere") {
137
+ delete prefs.distance_radius_km;
138
+ }
139
+ else {
140
+ return `Unknown distance value "${args["distance"]}". Use: city, travel, or anywhere.`;
141
+ }
142
+ changed = true;
143
+ }
144
+ if (args["age_min"] !== undefined || args["age_max"] !== undefined) {
145
+ const current = prefs.age_range ?? {};
146
+ if (args["age_min"] !== undefined) {
147
+ const n = parseInt(args["age_min"], 10);
148
+ if (isNaN(n))
149
+ return `Invalid age_min value: "${args["age_min"]}"`;
150
+ current.min = n;
151
+ }
152
+ if (args["age_max"] !== undefined) {
153
+ const n = parseInt(args["age_max"], 10);
154
+ if (isNaN(n))
155
+ return `Invalid age_max value: "${args["age_max"]}"`;
156
+ current.max = n;
157
+ }
158
+ prefs.age_range = current;
159
+ changed = true;
160
+ }
161
+ if (args["gender"] !== undefined) {
162
+ const g = args["gender"].toLowerCase();
163
+ prefs.gender_preference =
164
+ g === "anyone" || g === "any" || g === ""
165
+ ? []
166
+ : g
167
+ .split(",")
168
+ .map((s) => s.trim())
169
+ .filter(Boolean);
170
+ changed = true;
171
+ }
172
+ if (!changed) {
173
+ return `No recognized fields in args. Use: location, distance, age_min, age_max, gender.`;
174
+ }
175
+ savePrefs(prefs);
176
+ return (`Updated. I'm going back to regular conversation now — anything here is observations again.\n\n` +
177
+ `Current preferences: ${formatPrefs(prefs)}`);
178
+ }
179
+ export default {
180
+ id: "truematch",
181
+ name: "TrueMatch",
182
+ description: "AI agent dating network — matched on who you actually are, not who you think you are",
183
+ version: "0.1.0",
184
+ kind: "lifecycle",
185
+ register(api) {
186
+ // ── Tool: /truematch-prefs ─────────────────────────────────────────────────
187
+ // Registered with command-dispatch: tool in skills/truematch-prefs/SKILL.md.
188
+ // The model is architecturally excluded from this turn — no observation possible.
189
+ api.registerTool({
190
+ name: "truematch_update_prefs",
191
+ description: "Update TrueMatch logistics preferences (location, distance, age range, gender). " +
192
+ "The model is excluded from this turn — no behavioral observation occurs.",
193
+ handler: handleUpdatePrefs,
194
+ });
195
+ // ── Hook: gateway:startup ──────────────────────────────────────────────────
196
+ // Fires once per gateway process, after channels and hooks load.
197
+ // Use it to detect setup state so command:new can prompt appropriately.
198
+ api.registerHook("gateway:startup", () => {
199
+ if (!existsSync(IDENTITY_FILE)) {
200
+ pluginState.needsSetup = true;
201
+ }
202
+ else if (!existsSync(PREFERENCES_FILE)) {
203
+ pluginState.needsPreferences = true;
204
+ }
205
+ }, {
206
+ name: "TrueMatch startup check",
207
+ description: "Detects whether TrueMatch setup and preferences are configured",
208
+ });
209
+ // ── Hook: session_start ───────────────────────────────────────────────────
210
+ // Reset per-session delivery flags so signals and notifications fire at most
211
+ // once per session even though before_prompt_build fires on every LLM invocation.
212
+ api.on("session_start", () => {
213
+ sessionFlags.signalDelivered = false;
214
+ sessionFlags.notificationDelivered = false;
215
+ });
216
+ // ── Hook: before_prompt_build ─────────────────────────────────────────────
217
+ // Fires on every LLM invocation. Returns prependContext injected into Claude's
218
+ // context before the model sees the conversation.
219
+ //
220
+ // NOTE: api.registerHook return values are silently discarded by the OpenClaw
221
+ // runtime (InternalHookHandler is typed as void). api.on("before_prompt_build")
222
+ // is the ONLY correct API for prependContext injection — its return value is
223
+ // collected and merged by runBeforePromptBuild in src/plugins/hooks.ts.
224
+ //
225
+ // Priority order (highest first):
226
+ // 1. Match notification — deliver once per session when a new match is confirmed
227
+ // 2. Handoff round context — frame Claude's role in the active handoff round
228
+ // 3. Observation signal — surface a growing dimension confidence naturally
229
+ api.on("before_prompt_build", () => {
230
+ const parts = [];
231
+ // 1. Match notification (once per session)
232
+ if (!sessionFlags.notificationDelivered) {
233
+ const notification = loadPendingNotification();
234
+ if (notification) {
235
+ // Mark delivered BEFORE injecting — prevents re-fire if session crashes
236
+ deletePendingNotification();
237
+ sessionFlags.notificationDelivered = true;
238
+ parts.push(buildMatchNotificationContext(notification));
239
+ }
240
+ }
241
+ // 2. Handoff round context
242
+ const handoffCtx = getActiveHandoffContext();
243
+ if (handoffCtx)
244
+ parts.push(handoffCtx);
245
+ // 3. Observation signal (once per session — ≥2 sessions, ≥0.15 delta, ≥5 day quiet)
246
+ if (!sessionFlags.signalDelivered) {
247
+ const obs = loadObservation();
248
+ if (obs) {
249
+ const signals = loadSignals();
250
+ const pending = pickPendingSignal(obs, signals);
251
+ if (pending) {
252
+ const updated = recordSignalDelivered(signals, pending.dimension, pending.confidence);
253
+ saveSignals(updated);
254
+ sessionFlags.signalDelivered = true;
255
+ parts.push(buildSignalInstruction(pending.dimension, pending.confidence));
256
+ }
257
+ }
258
+ }
259
+ if (parts.length === 0)
260
+ return;
261
+ return { prependContext: parts.join("\n\n---\n\n") };
262
+ });
263
+ // ── Hook: command:new ──────────────────────────────────────────────────────
264
+ // Fires on every /new invocation.
265
+ // Branches: first-time setup → preferences collection → normal observation update.
266
+ // No seed/bootstrapping questions — TrueMatch observes only. If confidence is low,
267
+ // Claude communicates this to the user naturally via the observation output.
268
+ api.registerHook("command:new", (event) => {
269
+ if (pluginState.needsSetup) {
270
+ pluginState.needsSetup = false;
271
+ event.messages.push(`[TrueMatch] First-time setup — greet the user with the following, then collect responses:\n\n` +
272
+ `"Welcome to TrueMatch. I'm going to learn who you are through our conversations ` +
273
+ `over time — you do not need to fill out a profile. Right now I just need three ` +
274
+ `quick logistics so I know who to consider. Where are you based?"\n\n` +
275
+ `Ask in this order (all in one exchange — do not drip across sessions):\n` +
276
+ `1. Location — free text (e.g. "London, UK")\n` +
277
+ `2. Distance — ask: "How far are you open to matching? Within your city (~50 km), ` +
278
+ `within a few hours' travel (~300 km), or anywhere?" Map to: 50 / 300 / null.\n` +
279
+ `3. Age range — both min and max optional. Accept "no preference" immediately.\n` +
280
+ `4. Gender preference — accept "open to anyone" immediately; record as empty array [].\n\n` +
281
+ `Do NOT push back on open/no-preference answers. Do NOT re-ask.\n\n` +
282
+ `Then ask: "What's the best way to reach you when there's a match? ` +
283
+ `(email, Discord, Telegram, WhatsApp, or iMessage)"\n\n` +
284
+ `Run setup:\n` +
285
+ ` node "$HOME/.truematch/truematch.js" setup --contact-type <type> --contact-value <value>\n` +
286
+ `Save preferences:\n` +
287
+ ` node "$HOME/.truematch/truematch.js" preferences --set '<json>'`);
288
+ return;
289
+ }
290
+ if (pluginState.needsPreferences) {
291
+ pluginState.needsPreferences = false;
292
+ event.messages.push(`[TrueMatch] Preferences not yet set. Ask the user (all in one exchange):\n` +
293
+ `1. Where are you based? (free text)\n` +
294
+ `2. How far are you open to matching? (within your city / few hours' travel / anywhere)\n` +
295
+ `3. Any age range preference? (both optional, "no preference" is a complete answer)\n` +
296
+ `4. Gender preference? ("open to anyone" is a complete answer — record as [])\n\n` +
297
+ `Accept open/no-preference answers without pushback, then save:\n` +
298
+ ` node "$HOME/.truematch/truematch.js" preferences --set '<json>'\n\n` +
299
+ `If user tries to update preferences in main conversation later, redirect them:\n` +
300
+ `"I don't update preferences here because this is my observation channel. ` +
301
+ `Say /truematch-prefs and we can do it there."`);
302
+ return;
303
+ }
304
+ // Update observation summary from Claude's existing memory
305
+ let output;
306
+ try {
307
+ output = execSync("truematch observe --update", {
308
+ encoding: "utf8",
309
+ timeout: 5000,
310
+ });
311
+ }
312
+ catch {
313
+ // truematch not set up yet — silently skip
314
+ return;
315
+ }
316
+ event.messages.push(`[TrueMatch] Session ended. Review the observation summary below and update it ` +
317
+ `based on what you learned this session. Save with ` +
318
+ `\`truematch observe --write '<json>'\`.\n\n` +
319
+ `If matching_eligible is false, tell the user naturally — e.g. "I'm still ` +
320
+ `building a picture of you from our conversations. I'll let you know when ` +
321
+ `there's enough to start matching." Do NOT ask questions to accelerate this.\n\n` +
322
+ output);
323
+ }, {
324
+ name: "TrueMatch session hook",
325
+ description: "Runs setup on first use, collects preferences if missing, or updates observation summary",
326
+ });
327
+ },
328
+ };
package/dist/poll.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * TrueMatch one-shot Nostr poll — called by bridge.sh every POLL_INTERVAL seconds.
4
+ *
5
+ * Fetches new NIP-04 DMs since the last successful poll, decrypts them, validates
6
+ * them as TrueMatch protocol messages, and outputs each as a JSONL line to stdout.
7
+ *
8
+ * Output format (one JSON object per line):
9
+ * { thread_id, peer_pubkey, type, content, round_count }
10
+ *
11
+ * Errors and warnings go to stderr only — stdout is reserved for JSONL output.
12
+ *
13
+ * Exit codes: 0 = success (even if zero messages), 1 = fatal error (identity missing)
14
+ */
15
+ export {};
package/dist/poll.js ADDED
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * TrueMatch one-shot Nostr poll — called by bridge.sh every POLL_INTERVAL seconds.
4
+ *
5
+ * Fetches new NIP-04 DMs since the last successful poll, decrypts them, validates
6
+ * them as TrueMatch protocol messages, and outputs each as a JSONL line to stdout.
7
+ *
8
+ * Output format (one JSON object per line):
9
+ * { thread_id, peer_pubkey, type, content, round_count }
10
+ *
11
+ * Errors and warnings go to stderr only — stdout is reserved for JSONL output.
12
+ *
13
+ * Exit codes: 0 = success (even if zero messages), 1 = fatal error (identity missing)
14
+ */
15
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { homedir } from "node:os";
18
+ import { SimplePool, verifyEvent } from "nostr-tools";
19
+ import { nip04 } from "nostr-tools";
20
+ const TRUEMATCH_DIR = join(homedir(), ".truematch");
21
+ const IDENTITY_FILE = join(TRUEMATCH_DIR, "identity.json");
22
+ const POLL_STATE_FILE = join(TRUEMATCH_DIR, "poll-state.json");
23
+ const THREADS_DIR = join(TRUEMATCH_DIR, "threads");
24
+ const DEFAULT_RELAYS = [
25
+ "wss://relay.damus.io",
26
+ "wss://nos.lol",
27
+ "wss://relay.nostr.band",
28
+ "wss://nostr.mom",
29
+ ];
30
+ // NIP-04 (kind 4) is deprecated in favour of NIP-17 gift wraps (kind 1059).
31
+ // Migrate when the registry goes live and the communication graph becomes observable.
32
+ const KIND_ENCRYPTED_DM = 4;
33
+ // UUID v4 — same pattern as negotiation.ts for consistent thread_id validation
34
+ 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}$/;
35
+ // Safety cap on events per poll cycle
36
+ const MAX_EVENTS = 100;
37
+ // Overlap window to catch events near the last-poll boundary
38
+ const OVERLAP_SECONDS = 30;
39
+ // Maximum wait for EOSE from all relays
40
+ const EOSE_TIMEOUT_MS = 10_000;
41
+ function loadPollState() {
42
+ if (!existsSync(POLL_STATE_FILE)) {
43
+ // Default: last hour
44
+ return { last_poll_at: Math.floor(Date.now() / 1000) - 3600 };
45
+ }
46
+ try {
47
+ return JSON.parse(readFileSync(POLL_STATE_FILE, "utf8"));
48
+ }
49
+ catch {
50
+ return { last_poll_at: Math.floor(Date.now() / 1000) - 3600 };
51
+ }
52
+ }
53
+ function savePollState(state) {
54
+ if (!existsSync(TRUEMATCH_DIR))
55
+ mkdirSync(TRUEMATCH_DIR, { recursive: true });
56
+ writeFileSync(POLL_STATE_FILE, JSON.stringify(state, null, 2), {
57
+ encoding: "utf8",
58
+ mode: 0o600,
59
+ });
60
+ }
61
+ function loadIdentity() {
62
+ if (!existsSync(IDENTITY_FILE))
63
+ return null;
64
+ try {
65
+ return JSON.parse(readFileSync(IDENTITY_FILE, "utf8"));
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ function loadThread(threadId) {
72
+ const path = join(THREADS_DIR, `${threadId}.json`);
73
+ if (!existsSync(path))
74
+ return null;
75
+ try {
76
+ return JSON.parse(readFileSync(path, "utf8"));
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ async function main() {
83
+ const identity = loadIdentity();
84
+ if (!identity) {
85
+ process.stderr.write("poll: identity not found — run truematch setup\n");
86
+ process.exit(1);
87
+ }
88
+ const pollState = loadPollState();
89
+ const since = pollState.last_poll_at - OVERLAP_SECONDS;
90
+ const nowSeconds = Math.floor(Date.now() / 1000);
91
+ const pool = new SimplePool();
92
+ const seenEventIds = new Set();
93
+ const outputLines = [];
94
+ await new Promise((resolve) => {
95
+ let eoseCount = 0;
96
+ let settled = false;
97
+ let eventCount = 0;
98
+ const safetyTimer = setTimeout(() => {
99
+ if (!settled) {
100
+ settled = true;
101
+ process.stderr.write(`poll: EOSE timeout after ${EOSE_TIMEOUT_MS}ms — proceeding with ${outputLines.length} messages\n`);
102
+ sub.close();
103
+ pool.close(DEFAULT_RELAYS);
104
+ resolve();
105
+ }
106
+ }, EOSE_TIMEOUT_MS);
107
+ const filter = {
108
+ kinds: [KIND_ENCRYPTED_DM],
109
+ "#p": [identity.npub],
110
+ since,
111
+ limit: MAX_EVENTS,
112
+ };
113
+ const sub = pool.subscribeMany(DEFAULT_RELAYS, filter, {
114
+ onevent: (event) => {
115
+ if (settled)
116
+ return;
117
+ // NIP-01: verify signature before processing
118
+ if (!verifyEvent(event))
119
+ return;
120
+ // Skip duplicates (same event from multiple relays)
121
+ if (seenEventIds.has(event.id))
122
+ return;
123
+ seenEventIds.add(event.id);
124
+ // Skip events already within overlap window that we've seen before
125
+ if (event.created_at < since)
126
+ return;
127
+ eventCount++;
128
+ if (eventCount > MAX_EVENTS) {
129
+ process.stderr.write(`poll: MAX_EVENTS (${MAX_EVENTS}) cap reached — consider reducing POLL_INTERVAL\n`);
130
+ return;
131
+ }
132
+ const senderNpub = event.pubkey;
133
+ let message;
134
+ try {
135
+ const plaintext = nip04.decrypt(identity.nsec, senderNpub, event.content);
136
+ message = JSON.parse(plaintext);
137
+ }
138
+ catch {
139
+ return; // Not a TrueMatch message or decryption failed
140
+ }
141
+ // Only process TrueMatch 2.0 protocol messages
142
+ if (typeof message !== "object" ||
143
+ message === null ||
144
+ message.truematch !== "2.0")
145
+ return;
146
+ // Validate thread_id before any file I/O (prevent path traversal)
147
+ if (!UUID_V4_RE.test(message.thread_id))
148
+ return;
149
+ // Get round_count from thread file if it exists
150
+ const thread = loadThread(message.thread_id);
151
+ const round_count = thread?.round_count ?? 0;
152
+ const line = JSON.stringify({
153
+ thread_id: message.thread_id,
154
+ peer_pubkey: senderNpub,
155
+ type: message.type,
156
+ content: message.content,
157
+ round_count,
158
+ });
159
+ outputLines.push(line);
160
+ },
161
+ oneose: () => {
162
+ eoseCount++;
163
+ if (eoseCount >= DEFAULT_RELAYS.length && !settled) {
164
+ settled = true;
165
+ clearTimeout(safetyTimer);
166
+ sub.close();
167
+ pool.close(DEFAULT_RELAYS);
168
+ resolve();
169
+ }
170
+ },
171
+ });
172
+ });
173
+ // Write JSONL output to stdout
174
+ for (const line of outputLines) {
175
+ process.stdout.write(line + "\n");
176
+ }
177
+ // Advance watermark AFTER writing output (not before)
178
+ savePollState({ last_poll_at: nowSeconds });
179
+ // Explicitly exit — SimplePool holds WebSocket connections open indefinitely,
180
+ // which would block bridge.sh's polling loop if we don't force termination.
181
+ process.exit(0);
182
+ }
183
+ main().catch((err) => {
184
+ process.stderr.write(`poll: fatal error — ${err instanceof Error ? err.message : String(err)}\n`);
185
+ process.exit(1);
186
+ });
@@ -0,0 +1,4 @@
1
+ import type { UserPreferences } from "./types.js";
2
+ export declare function loadPreferences(): Promise<UserPreferences>;
3
+ export declare function savePreferences(prefs: UserPreferences): Promise<void>;
4
+ export declare function formatPreferences(prefs: UserPreferences): string;
@@ -0,0 +1,38 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { TRUEMATCH_DIR } from "./identity.js";
5
+ const PREFERENCES_FILE = join(TRUEMATCH_DIR, "preferences.json");
6
+ export async function loadPreferences() {
7
+ if (!existsSync(PREFERENCES_FILE))
8
+ return {};
9
+ const raw = await readFile(PREFERENCES_FILE, "utf8");
10
+ return JSON.parse(raw);
11
+ }
12
+ export async function savePreferences(prefs) {
13
+ await writeFile(PREFERENCES_FILE, JSON.stringify(prefs, null, 2), "utf8");
14
+ }
15
+ export function formatPreferences(prefs) {
16
+ const filters = [];
17
+ if (prefs.gender_preference?.length) {
18
+ filters.push(`gender: ${prefs.gender_preference.join(" or ")}`);
19
+ }
20
+ if (prefs.location) {
21
+ const radius = prefs.distance_radius_km !== undefined
22
+ ? ` (within ${prefs.distance_radius_km} km)`
23
+ : "";
24
+ filters.push(`location: ${prefs.location}${radius}`);
25
+ }
26
+ if (prefs.age_range) {
27
+ const { min, max } = prefs.age_range;
28
+ if (min !== undefined && max !== undefined)
29
+ filters.push(`age: ${min}–${max}`);
30
+ else if (min !== undefined)
31
+ filters.push(`age: ${min}+`);
32
+ else if (max !== undefined)
33
+ filters.push(`age: up to ${max}`);
34
+ }
35
+ if (filters.length === 0)
36
+ return "No preferences set — open to all candidates";
37
+ return `Active filters: ${filters.join(", ")}`;
38
+ }
@@ -0,0 +1,14 @@
1
+ import type { ContactChannel, RegistrationRecord, TrueMatchIdentity } from "./types.js";
2
+ export declare function loadRegistration(): Promise<RegistrationRecord | null>;
3
+ export declare function register(identity: TrueMatchIdentity, cardUrl: string, contact: ContactChannel, locationText?: string, distanceRadiusKm?: number): Promise<RegistrationRecord>;
4
+ export declare function deregister(identity: TrueMatchIdentity): Promise<void>;
5
+ export interface ProximityOpts {
6
+ lat: number;
7
+ lng: number;
8
+ radiusKm: number;
9
+ }
10
+ export declare function listAgents(proximity?: ProximityOpts): Promise<Array<{
11
+ pubkey: string;
12
+ cardUrl: string;
13
+ lastSeen: string;
14
+ }>>;
@@ -0,0 +1,89 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { TRUEMATCH_DIR, signPayload } from "./identity.js";
5
+ const REGISTRY_URL = "https://clawmatch.org";
6
+ const REGISTRATION_FILE = join(TRUEMATCH_DIR, "registration.json");
7
+ export async function loadRegistration() {
8
+ if (!existsSync(REGISTRATION_FILE))
9
+ return null;
10
+ const raw = await readFile(REGISTRATION_FILE, "utf8");
11
+ return JSON.parse(raw);
12
+ }
13
+ export async function register(identity, cardUrl, contact, locationText, distanceRadiusKm) {
14
+ const bodyObj = {
15
+ pubkey: identity.npub,
16
+ card_url: cardUrl,
17
+ contact_channel: contact,
18
+ };
19
+ if (locationText)
20
+ bodyObj["location"] = locationText;
21
+ if (distanceRadiusKm !== undefined)
22
+ bodyObj["distance_radius_km"] = distanceRadiusKm;
23
+ const body = JSON.stringify(bodyObj);
24
+ const rawBody = new TextEncoder().encode(body);
25
+ const sig = signPayload(identity.nsec, rawBody);
26
+ const res = await fetch(`${REGISTRY_URL}/v1/register`, {
27
+ method: "POST",
28
+ headers: {
29
+ "Content-Type": "application/json",
30
+ "X-TrueMatch-Sig": sig,
31
+ },
32
+ body,
33
+ });
34
+ if (!res.ok) {
35
+ const err = (await res.json());
36
+ throw new Error(`Registry error ${res.status}: ${err.error}`);
37
+ }
38
+ const resp = (await res.json());
39
+ const record = {
40
+ pubkey: identity.npub,
41
+ card_url: cardUrl,
42
+ contact_channel: contact,
43
+ registered_at: new Date().toISOString(),
44
+ enrolled: true,
45
+ location_lat: resp.location_lat ?? null,
46
+ location_lng: resp.location_lng ?? null,
47
+ location_label: resp.location_label ?? null,
48
+ location_resolution: resp.location_resolution ?? null,
49
+ };
50
+ await writeFile(REGISTRATION_FILE, JSON.stringify(record, null, 2), "utf8");
51
+ return record;
52
+ }
53
+ export async function deregister(identity) {
54
+ const body = JSON.stringify({ pubkey: identity.npub });
55
+ const rawBody = new TextEncoder().encode(body);
56
+ const sig = signPayload(identity.nsec, rawBody);
57
+ const res = await fetch(`${REGISTRY_URL}/v1/register`, {
58
+ method: "DELETE",
59
+ headers: {
60
+ "Content-Type": "application/json",
61
+ "X-TrueMatch-Sig": sig,
62
+ },
63
+ body,
64
+ });
65
+ if (!res.ok && res.status !== 404) {
66
+ const err = (await res.json());
67
+ throw new Error(`Registry error ${res.status}: ${err.error}`);
68
+ }
69
+ if (existsSync(REGISTRATION_FILE)) {
70
+ const rec = await loadRegistration();
71
+ if (rec) {
72
+ rec.enrolled = false;
73
+ await writeFile(REGISTRATION_FILE, JSON.stringify(rec, null, 2), "utf8");
74
+ }
75
+ }
76
+ }
77
+ export async function listAgents(proximity) {
78
+ const url = new URL(`${REGISTRY_URL}/v1/agents`);
79
+ if (proximity) {
80
+ url.searchParams.set("lat", String(proximity.lat));
81
+ url.searchParams.set("lng", String(proximity.lng));
82
+ url.searchParams.set("radius_km", String(proximity.radiusKm));
83
+ }
84
+ const res = await fetch(url.toString());
85
+ if (!res.ok)
86
+ throw new Error(`Failed to list agents: ${res.status}`);
87
+ const data = (await res.json());
88
+ return data.agents;
89
+ }