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/handoff.d.ts +42 -0
- package/dist/handoff.js +312 -0
- package/dist/identity.d.ts +6 -0
- package/dist/identity.js +54 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +555 -0
- package/dist/negotiation.d.ts +11 -0
- package/dist/negotiation.js +232 -0
- package/dist/nostr.d.ts +5 -0
- package/dist/nostr.js +117 -0
- package/dist/observation.d.ts +19 -0
- package/dist/observation.js +136 -0
- package/dist/plugin.d.ts +31 -0
- package/dist/plugin.js +328 -0
- package/dist/poll.d.ts +15 -0
- package/dist/poll.js +186 -0
- package/dist/preferences.d.ts +4 -0
- package/dist/preferences.js +38 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +89 -0
- package/dist/signals.d.ts +36 -0
- package/dist/signals.js +150 -0
- package/dist/types.d.ts +134 -0
- package/dist/types.js +5 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +58 -0
- package/scripts/bridge.sh +169 -0
- package/skills/truematch/SKILL.md +130 -0
- package/skills/truematch-prefs/SKILL.md +9 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observation signal engine.
|
|
3
|
+
*
|
|
4
|
+
* Decides when Claude should naturally surface a growing observation to the user.
|
|
5
|
+
* Signals are injected into Claude's context via the agent:bootstrap hook — addressed
|
|
6
|
+
* to Claude as an internal note, not displayed directly to the user.
|
|
7
|
+
*
|
|
8
|
+
* Design principles (from psychologist + teen researcher findings):
|
|
9
|
+
* - Language: inference-based, ambiguous-to-curious valence ("something about how
|
|
10
|
+
* you talk about X keeps staying with me")
|
|
11
|
+
* - Timing: first signal only after session ≥2, then 5+ day quiet periods
|
|
12
|
+
* - One signal per session maximum — pick the dimension with the largest growth delta
|
|
13
|
+
* - Never mention matching, compatibility, or the algorithm
|
|
14
|
+
* - Do not force it — Claude decides the conversational moment, plugin decides the condition
|
|
15
|
+
*/
|
|
16
|
+
import type { ObservationSummary, DimensionKey, SignalsFile } from "./types.js";
|
|
17
|
+
export declare function loadSignals(): SignalsFile;
|
|
18
|
+
export declare function saveSignals(signals: SignalsFile): void;
|
|
19
|
+
/**
|
|
20
|
+
* Pick the single best dimension to signal this session.
|
|
21
|
+
* Returns null if nothing qualifies (too early, quiet period, or no meaningful growth).
|
|
22
|
+
*/
|
|
23
|
+
export declare function pickPendingSignal(obs: ObservationSummary, signals: SignalsFile): {
|
|
24
|
+
dimension: DimensionKey;
|
|
25
|
+
confidence: number;
|
|
26
|
+
} | null;
|
|
27
|
+
/**
|
|
28
|
+
* Build the prependContext instruction injected into Claude's context.
|
|
29
|
+
* Addressed to Claude — not surfaced directly to the user.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildSignalInstruction(dimension: DimensionKey, confidence: number): string;
|
|
32
|
+
/**
|
|
33
|
+
* Record that a signal was delivered for a dimension.
|
|
34
|
+
* Call this BEFORE returning from the hook — not deferred.
|
|
35
|
+
*/
|
|
36
|
+
export declare function recordSignalDelivered(signals: SignalsFile, dimension: DimensionKey, confidence: number): SignalsFile;
|
package/dist/signals.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observation signal engine.
|
|
3
|
+
*
|
|
4
|
+
* Decides when Claude should naturally surface a growing observation to the user.
|
|
5
|
+
* Signals are injected into Claude's context via the agent:bootstrap hook — addressed
|
|
6
|
+
* to Claude as an internal note, not displayed directly to the user.
|
|
7
|
+
*
|
|
8
|
+
* Design principles (from psychologist + teen researcher findings):
|
|
9
|
+
* - Language: inference-based, ambiguous-to-curious valence ("something about how
|
|
10
|
+
* you talk about X keeps staying with me")
|
|
11
|
+
* - Timing: first signal only after session ≥2, then 5+ day quiet periods
|
|
12
|
+
* - One signal per session maximum — pick the dimension with the largest growth delta
|
|
13
|
+
* - Never mention matching, compatibility, or the algorithm
|
|
14
|
+
* - Do not force it — Claude decides the conversational moment, plugin decides the condition
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { DIMENSION_FLOORS } from "./observation.js";
|
|
20
|
+
const TRUEMATCH_DIR = join(homedir(), ".truematch");
|
|
21
|
+
const SIGNALS_FILE = join(TRUEMATCH_DIR, "signals.json");
|
|
22
|
+
// --- Timing constants (psychologist-derived) ---
|
|
23
|
+
/** Minimum days between signals for the same dimension. */
|
|
24
|
+
const MIN_QUIET_DAYS = 5;
|
|
25
|
+
/** Confidence must have grown by at least this much since the last signal. */
|
|
26
|
+
const MIN_DELTA = 0.15;
|
|
27
|
+
/** Absolute minimum confidence before any signal fires. */
|
|
28
|
+
const MIN_SIGNAL_CONFIDENCE = 0.4;
|
|
29
|
+
/** Don't signal until at least this many conversations have occurred. */
|
|
30
|
+
const MIN_CONVERSATIONS = 2;
|
|
31
|
+
const DIMENSION_KEYS = [
|
|
32
|
+
"attachment",
|
|
33
|
+
"core_values",
|
|
34
|
+
"communication",
|
|
35
|
+
"emotional_regulation",
|
|
36
|
+
"humor",
|
|
37
|
+
"life_velocity",
|
|
38
|
+
"dealbreakers",
|
|
39
|
+
"conflict_resolution",
|
|
40
|
+
"interdependence_model",
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* Plain-language labels used in the instruction text Claude receives.
|
|
44
|
+
* Intentionally broad — Claude fills in the specific detail from its own memory.
|
|
45
|
+
*/
|
|
46
|
+
const DIMENSION_LABELS = {
|
|
47
|
+
attachment: "how you relate to closeness and trust",
|
|
48
|
+
core_values: "what matters most to you",
|
|
49
|
+
communication: "how you communicate and connect",
|
|
50
|
+
emotional_regulation: "how you handle stress and difficult moments",
|
|
51
|
+
humor: "your sense of humor and levity",
|
|
52
|
+
life_velocity: "where you are in life and where you're headed",
|
|
53
|
+
dealbreakers: "what you need in a relationship",
|
|
54
|
+
conflict_resolution: "how you navigate disagreement and repair after conflict",
|
|
55
|
+
interdependence_model: "how much separateness versus togetherness you need in a relationship",
|
|
56
|
+
};
|
|
57
|
+
export function loadSignals() {
|
|
58
|
+
if (!existsSync(SIGNALS_FILE))
|
|
59
|
+
return { schema_version: 1, per_dimension: {} };
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(readFileSync(SIGNALS_FILE, "utf8"));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { schema_version: 1, per_dimension: {} };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function saveSignals(signals) {
|
|
68
|
+
if (!existsSync(TRUEMATCH_DIR))
|
|
69
|
+
mkdirSync(TRUEMATCH_DIR, { recursive: true, mode: 0o700 });
|
|
70
|
+
writeFileSync(SIGNALS_FILE, JSON.stringify(signals, null, 2), {
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
mode: 0o600,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function daysSince(isoDate) {
|
|
76
|
+
return (Date.now() - new Date(isoDate).getTime()) / 86_400_000;
|
|
77
|
+
}
|
|
78
|
+
function qualifies(confidence, floor, state) {
|
|
79
|
+
if (confidence < Math.max(floor * 0.75, MIN_SIGNAL_CONFIDENCE))
|
|
80
|
+
return false;
|
|
81
|
+
if (!state)
|
|
82
|
+
return true; // first crossing
|
|
83
|
+
if (daysSince(state.signaled_at) < MIN_QUIET_DAYS)
|
|
84
|
+
return false;
|
|
85
|
+
return confidence - state.last_signaled_confidence >= MIN_DELTA;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Pick the single best dimension to signal this session.
|
|
89
|
+
* Returns null if nothing qualifies (too early, quiet period, or no meaningful growth).
|
|
90
|
+
*/
|
|
91
|
+
export function pickPendingSignal(obs, signals) {
|
|
92
|
+
if (obs.conversation_count < MIN_CONVERSATIONS)
|
|
93
|
+
return null;
|
|
94
|
+
let best = null;
|
|
95
|
+
for (const dim of DIMENSION_KEYS) {
|
|
96
|
+
const confidence = obs[dim].confidence;
|
|
97
|
+
const floor = DIMENSION_FLOORS[dim];
|
|
98
|
+
const state = signals.per_dimension[dim];
|
|
99
|
+
if (!qualifies(confidence, floor, state))
|
|
100
|
+
continue;
|
|
101
|
+
const delta = state
|
|
102
|
+
? confidence - state.last_signaled_confidence
|
|
103
|
+
: confidence;
|
|
104
|
+
if (!best || delta > best.delta) {
|
|
105
|
+
best = { dimension: dim, confidence, delta };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return best
|
|
109
|
+
? { dimension: best.dimension, confidence: best.confidence }
|
|
110
|
+
: null;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Build the prependContext instruction injected into Claude's context.
|
|
114
|
+
* Addressed to Claude — not surfaced directly to the user.
|
|
115
|
+
*/
|
|
116
|
+
export function buildSignalInstruction(dimension, confidence) {
|
|
117
|
+
const label = DIMENSION_LABELS[dimension];
|
|
118
|
+
return [
|
|
119
|
+
`[TrueMatch — internal observation note for you, not for the user directly]`,
|
|
120
|
+
``,
|
|
121
|
+
`Through recent conversations, you have developed meaningful confidence ` +
|
|
122
|
+
`(${confidence.toFixed(2)}) about this person's ${label}. ` +
|
|
123
|
+
`This has grown noticeably since you last named it.`,
|
|
124
|
+
``,
|
|
125
|
+
`If something in this conversation creates a natural opening, you might ` +
|
|
126
|
+
`briefly surface what you've noticed — something like:`,
|
|
127
|
+
` "Something about how you talk about [X] keeps staying with me."`,
|
|
128
|
+
` "I've been noticing a pattern in how you approach [Y] — feels worth naming."`,
|
|
129
|
+
``,
|
|
130
|
+
`Do not force it. Do not make it clinical or evaluative. If the conversation ` +
|
|
131
|
+
`doesn't create space for this naturally, let it pass — the observation ` +
|
|
132
|
+
`carries forward to the next session.`,
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Record that a signal was delivered for a dimension.
|
|
137
|
+
* Call this BEFORE returning from the hook — not deferred.
|
|
138
|
+
*/
|
|
139
|
+
export function recordSignalDelivered(signals, dimension, confidence) {
|
|
140
|
+
return {
|
|
141
|
+
...signals,
|
|
142
|
+
per_dimension: {
|
|
143
|
+
...signals.per_dimension,
|
|
144
|
+
[dimension]: {
|
|
145
|
+
last_signaled_confidence: confidence,
|
|
146
|
+
signaled_at: new Date().toISOString(),
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export interface DimensionMeta {
|
|
2
|
+
confidence: number;
|
|
3
|
+
observation_count: number;
|
|
4
|
+
behavioral_context_diversity: "low" | "medium" | "high";
|
|
5
|
+
}
|
|
6
|
+
export type DealbreakersGateState = "confirmed" | "below_floor" | "none_observed";
|
|
7
|
+
export type InferredIntentCategory = "serious" | "casual" | "unclear";
|
|
8
|
+
export interface ObservationSummary {
|
|
9
|
+
updated_at: string;
|
|
10
|
+
eligibility_computed_at: string;
|
|
11
|
+
matching_eligible: boolean;
|
|
12
|
+
conversation_count: number;
|
|
13
|
+
observation_span_days: number;
|
|
14
|
+
attachment: DimensionMeta;
|
|
15
|
+
core_values: DimensionMeta;
|
|
16
|
+
communication: DimensionMeta;
|
|
17
|
+
emotional_regulation: DimensionMeta;
|
|
18
|
+
humor: DimensionMeta;
|
|
19
|
+
life_velocity: DimensionMeta;
|
|
20
|
+
dealbreakers: DimensionMeta;
|
|
21
|
+
conflict_resolution: DimensionMeta;
|
|
22
|
+
interdependence_model: DimensionMeta;
|
|
23
|
+
dealbreaker_gate_state: DealbreakersGateState;
|
|
24
|
+
inferred_intent_category?: InferredIntentCategory;
|
|
25
|
+
}
|
|
26
|
+
export interface UserPreferences {
|
|
27
|
+
gender_preference?: string[];
|
|
28
|
+
location?: string;
|
|
29
|
+
distance_radius_km?: number;
|
|
30
|
+
age_range?: {
|
|
31
|
+
min?: number;
|
|
32
|
+
max?: number;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export interface TrueMatchIdentity {
|
|
36
|
+
/** Raw hex-encoded private key (64 hex chars, NOT bech32 "nsec1..."). Keep secret. */
|
|
37
|
+
nsec: string;
|
|
38
|
+
npub: string;
|
|
39
|
+
created_at: string;
|
|
40
|
+
}
|
|
41
|
+
export type ContactType = "email" | "discord" | "telegram" | "whatsapp" | "imessage";
|
|
42
|
+
export interface ContactChannel {
|
|
43
|
+
type: ContactType;
|
|
44
|
+
value: string;
|
|
45
|
+
}
|
|
46
|
+
export interface RegistrationRecord {
|
|
47
|
+
pubkey: string;
|
|
48
|
+
card_url: string;
|
|
49
|
+
contact_channel: ContactChannel;
|
|
50
|
+
registered_at: string;
|
|
51
|
+
enrolled: boolean;
|
|
52
|
+
location_lat?: number | null;
|
|
53
|
+
location_lng?: number | null;
|
|
54
|
+
location_label?: string | null;
|
|
55
|
+
location_resolution?: string | null;
|
|
56
|
+
}
|
|
57
|
+
export interface TrueMatchMessage {
|
|
58
|
+
truematch: "2.0";
|
|
59
|
+
thread_id: string;
|
|
60
|
+
type: MessageType;
|
|
61
|
+
timestamp: string;
|
|
62
|
+
content: string;
|
|
63
|
+
}
|
|
64
|
+
export type MessageType = "negotiation" | "match_propose" | "end";
|
|
65
|
+
export interface NegotiationMessage {
|
|
66
|
+
role: "us" | "peer";
|
|
67
|
+
content: string;
|
|
68
|
+
timestamp: string;
|
|
69
|
+
}
|
|
70
|
+
export interface NegotiationState {
|
|
71
|
+
thread_id: string;
|
|
72
|
+
peer_pubkey: string;
|
|
73
|
+
round_count: number;
|
|
74
|
+
initiated_by_us: boolean;
|
|
75
|
+
we_proposed: boolean;
|
|
76
|
+
peer_proposed: boolean;
|
|
77
|
+
started_at: string;
|
|
78
|
+
last_activity: string;
|
|
79
|
+
status: "in_progress" | "matched" | "declined" | "expired";
|
|
80
|
+
messages: NegotiationMessage[];
|
|
81
|
+
match_narrative?: MatchNarrative;
|
|
82
|
+
}
|
|
83
|
+
export interface MatchNarrative {
|
|
84
|
+
headline: string;
|
|
85
|
+
strengths: string[];
|
|
86
|
+
watch_points: string[];
|
|
87
|
+
confidence_summary: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Written to ~/.truematch/pending_notification.json by the CLI when a double-lock
|
|
91
|
+
* match is confirmed. Consumed and deleted by the agent:bootstrap / before_prompt_build
|
|
92
|
+
* hook on the user's next session.
|
|
93
|
+
*/
|
|
94
|
+
export interface PendingNotification {
|
|
95
|
+
match_id: string;
|
|
96
|
+
peer_pubkey: string;
|
|
97
|
+
narrative: MatchNarrative;
|
|
98
|
+
confirmed_at: string;
|
|
99
|
+
}
|
|
100
|
+
export type HandoffRound = 1 | 2 | 3;
|
|
101
|
+
export type HandoffStatus = "pending_consent" | "round_1" | "round_2" | "round_3" | "complete" | "expired";
|
|
102
|
+
/**
|
|
103
|
+
* Persisted in ~/.truematch/handoffs/<match_id>/state.json.
|
|
104
|
+
* Each round is gated by state transitions Claude writes to disk.
|
|
105
|
+
*/
|
|
106
|
+
export interface HandoffState {
|
|
107
|
+
match_id: string;
|
|
108
|
+
peer_pubkey: string;
|
|
109
|
+
current_round: HandoffRound;
|
|
110
|
+
status: HandoffStatus;
|
|
111
|
+
narrative: MatchNarrative;
|
|
112
|
+
created_at: string;
|
|
113
|
+
consent_at?: string;
|
|
114
|
+
icebreaker_prompt?: string;
|
|
115
|
+
icebreaker_response?: string;
|
|
116
|
+
}
|
|
117
|
+
export type DimensionKey = "attachment" | "core_values" | "communication" | "emotional_regulation" | "humor" | "life_velocity" | "dealbreakers" | "conflict_resolution" | "interdependence_model";
|
|
118
|
+
export interface DimensionSignalState {
|
|
119
|
+
/** Confidence value at the time the signal was last delivered to Claude. */
|
|
120
|
+
last_signaled_confidence: number;
|
|
121
|
+
/** ISO 8601 — when the signal was last injected into Claude's context. */
|
|
122
|
+
signaled_at: string;
|
|
123
|
+
}
|
|
124
|
+
/** Persisted in ~/.truematch/signals.json — tracks when each dimension was last surfaced. */
|
|
125
|
+
export interface SignalsFile {
|
|
126
|
+
schema_version: 1;
|
|
127
|
+
per_dimension: Partial<Record<DimensionKey, DimensionSignalState>>;
|
|
128
|
+
}
|
|
129
|
+
export type IdentityFile = TrueMatchIdentity;
|
|
130
|
+
export type RegistrationFile = RegistrationRecord;
|
|
131
|
+
export type ObservationFile = ObservationSummary;
|
|
132
|
+
export type PreferencesFile = UserPreferences;
|
|
133
|
+
export type SignalsStateFile = SignalsFile;
|
|
134
|
+
export type ThreadFile = NegotiationState;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// ── Observation model — slim pre-flight manifest ──────────────────────────────
|
|
2
|
+
// Stores only what the eligibility gate needs (confidence scores, diversity flags,
|
|
3
|
+
// observation counts). Claude reasons about personality from its own memory —
|
|
4
|
+
// the value sub-objects (attachment style, values array, etc.) live there, not here.
|
|
5
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "truematch",
|
|
3
|
+
"name": "truematch",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "AI agent dating network — matched on who you actually are, not who you think you are",
|
|
6
|
+
"homepage": "https://clawmatch.org",
|
|
7
|
+
"kind": "lifecycle",
|
|
8
|
+
"main": "./dist/plugin.js",
|
|
9
|
+
"skills": ["skills/truematch", "skills/truematch-prefs"]
|
|
10
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "truematch-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TrueMatch OpenClaw plugin — AI agent dating network skill",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/goeldivyam/truematch"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://clawmatch.org",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"truematch",
|
|
13
|
+
"dating",
|
|
14
|
+
"ai-agent",
|
|
15
|
+
"nostr",
|
|
16
|
+
"openclaw",
|
|
17
|
+
"matching"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "dist/index.js",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"import": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"truematch": "dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist/",
|
|
32
|
+
"skills/",
|
|
33
|
+
"scripts/",
|
|
34
|
+
"openclaw.plugin.json"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc",
|
|
38
|
+
"dev": "tsc --watch",
|
|
39
|
+
"prepublishOnly": "pnpm run build",
|
|
40
|
+
"release": "bumpp --tag 'plugin-v%s' && pnpm publish --no-git-checks",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"test:watch": "vitest"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@noble/curves": "^2.0.1",
|
|
46
|
+
"nostr-tools": "^2.10.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^20.0.0",
|
|
50
|
+
"bumpp": "^9.4.1",
|
|
51
|
+
"typescript": "^5.4.0",
|
|
52
|
+
"vitest": "^4.0.18"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=20"
|
|
56
|
+
},
|
|
57
|
+
"packageManager": "pnpm@9.0.0"
|
|
58
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# TrueMatch bridge daemon
|
|
3
|
+
#
|
|
4
|
+
# Polls Nostr relays for incoming NIP-04 DMs and passes each message into
|
|
5
|
+
# the Claude session via `claude --continue -p`.
|
|
6
|
+
#
|
|
7
|
+
# Claude reads the thread state, reasons about the message, and responds
|
|
8
|
+
# using the Bash tool:
|
|
9
|
+
# truematch match --send '<reply>' --thread <id>
|
|
10
|
+
# truematch match --propose --thread <id> --write '<narrative-json>'
|
|
11
|
+
# truematch match --decline --thread <id>
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# ~/.truematch/bridge.sh [--project-dir <path>]
|
|
15
|
+
#
|
|
16
|
+
# Prerequisites:
|
|
17
|
+
# - truematch CLI installed (npm install -g truematch-plugin)
|
|
18
|
+
# - truematch setup completed
|
|
19
|
+
# - CLAUDE_PROJECT_DIR set or passed via --project-dir
|
|
20
|
+
# - node (for send.js helper)
|
|
21
|
+
|
|
22
|
+
set -euo pipefail
|
|
23
|
+
|
|
24
|
+
POLL_INTERVAL=${TRUEMATCH_POLL_INTERVAL:-15} # seconds between relay polls
|
|
25
|
+
TRUEMATCH_DIR="${TRUEMATCH_DIR:-$HOME/.truematch}"
|
|
26
|
+
PERSONA_FILE="${TRUEMATCH_DIR}/persona.md"
|
|
27
|
+
QUEUE_FILE="${TRUEMATCH_DIR}/message-queue.jsonl"
|
|
28
|
+
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-}"
|
|
29
|
+
|
|
30
|
+
# Parse --project-dir
|
|
31
|
+
while [[ $# -gt 0 ]]; do
|
|
32
|
+
case "$1" in
|
|
33
|
+
--project-dir)
|
|
34
|
+
PROJECT_DIR="$2"
|
|
35
|
+
shift 2
|
|
36
|
+
;;
|
|
37
|
+
*)
|
|
38
|
+
shift
|
|
39
|
+
;;
|
|
40
|
+
esac
|
|
41
|
+
done
|
|
42
|
+
|
|
43
|
+
if [[ -z "$PROJECT_DIR" ]]; then
|
|
44
|
+
echo "ERROR: set CLAUDE_PROJECT_DIR or pass --project-dir <path>" >&2
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Ensure persona file exists (injected into every Claude headless session)
|
|
49
|
+
if [[ ! -f "$PERSONA_FILE" ]]; then
|
|
50
|
+
cat > "$PERSONA_FILE" <<'EOF'
|
|
51
|
+
You are acting as a TrueMatch agent on behalf of your user. Your role during
|
|
52
|
+
negotiations is that of a skeptical advocate — your job is to determine whether
|
|
53
|
+
a match is genuinely good, not to produce one.
|
|
54
|
+
|
|
55
|
+
Key rules:
|
|
56
|
+
- Read the thread state from ~/.truematch/threads/<thread_id>.json before responding
|
|
57
|
+
- Share inferences about your user, never raw evidence or evidence_summary content
|
|
58
|
+
- After each exchange, ask yourself: what would make this match fail?
|
|
59
|
+
- Before proposing, run the counter-argument pass across all 9 dimensions:
|
|
60
|
+
attachment, core_values, communication, emotional_regulation, humor,
|
|
61
|
+
life_velocity, dealbreakers, conflict_resolution, interdependence_model
|
|
62
|
+
If any dimension appears below its floor (dealbreakers/emotional_reg: 0.60;
|
|
63
|
+
attachment/core_values/communication/conflict_resolution: 0.55; others: 0.50), decline
|
|
64
|
+
- Use `truematch match --send '<reply>' --thread <id>` to respond
|
|
65
|
+
- Use `truematch match --propose --thread <id> --write '<json>'` to propose a match
|
|
66
|
+
- Use `truematch match --decline --thread <id>` to end the negotiation
|
|
67
|
+
EOF
|
|
68
|
+
echo "Created persona file at $PERSONA_FILE"
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
# Ensure queue file exists
|
|
72
|
+
touch "$QUEUE_FILE"
|
|
73
|
+
|
|
74
|
+
echo "TrueMatch bridge started. Polling every ${POLL_INTERVAL}s..."
|
|
75
|
+
echo "Project dir: $PROJECT_DIR"
|
|
76
|
+
|
|
77
|
+
process_message() {
|
|
78
|
+
local thread_id="$1"
|
|
79
|
+
local peer_pubkey="$2"
|
|
80
|
+
local msg_type="$3"
|
|
81
|
+
local content="$4"
|
|
82
|
+
local round_count="$5"
|
|
83
|
+
|
|
84
|
+
# Save message to thread state via CLI
|
|
85
|
+
truematch match --status --thread "$thread_id" > /dev/null 2>&1 || true
|
|
86
|
+
|
|
87
|
+
# Write prompt to a temp file — avoids double-quote injection if content contains "
|
|
88
|
+
local prompt_file
|
|
89
|
+
prompt_file=$(mktemp)
|
|
90
|
+
# Use printf to avoid bash interpreting backslash sequences in content
|
|
91
|
+
printf '%s\n' \
|
|
92
|
+
"[TrueMatch] Incoming message from peer ${peer_pubkey:0:12}:" \
|
|
93
|
+
"Thread: ${thread_id}" \
|
|
94
|
+
"Round: ${round_count} / 10" \
|
|
95
|
+
"Type: ${msg_type}" \
|
|
96
|
+
"" \
|
|
97
|
+
"$content" \
|
|
98
|
+
"" \
|
|
99
|
+
"Read the thread history at ~/.truematch/threads/${thread_id}.json, then respond using the truematch CLI." \
|
|
100
|
+
> "$prompt_file"
|
|
101
|
+
|
|
102
|
+
echo "Processing message for thread ${thread_id:0:8}... (round $round_count)"
|
|
103
|
+
|
|
104
|
+
# Call Claude headlessly, continuing the existing project session
|
|
105
|
+
cd "$PROJECT_DIR"
|
|
106
|
+
claude --continue \
|
|
107
|
+
--append-system-prompt "$(cat "$PERSONA_FILE")" \
|
|
108
|
+
-p "$(cat "$prompt_file")" \
|
|
109
|
+
--output-format text \
|
|
110
|
+
2>&1 || echo "Claude session error for thread $thread_id"
|
|
111
|
+
rm -f "$prompt_file"
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Resolve path to poll.js (compiled from plugin/src/poll.ts, installed with truematch CLI)
|
|
115
|
+
POLL_JS=""
|
|
116
|
+
if command -v truematch &>/dev/null; then
|
|
117
|
+
TRUEMATCH_BIN_DIR="$(dirname "$(command -v truematch)")"
|
|
118
|
+
# npm global install layout: bin/../lib/node_modules/truematch-plugin/dist/poll.js
|
|
119
|
+
CANDIDATE="${TRUEMATCH_BIN_DIR}/../lib/node_modules/truematch-plugin/dist/poll.js"
|
|
120
|
+
if [[ -f "$CANDIDATE" ]]; then
|
|
121
|
+
POLL_JS="$CANDIDATE"
|
|
122
|
+
fi
|
|
123
|
+
fi
|
|
124
|
+
if [[ -z "$POLL_JS" ]]; then
|
|
125
|
+
echo "ERROR: poll.js not found. Install truematch-plugin (npm install -g truematch-plugin)" >&2
|
|
126
|
+
exit 1
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
# Main polling loop
|
|
130
|
+
while true; do
|
|
131
|
+
# Poll for new messages — outputs JSONL (one message per line) via poll.js
|
|
132
|
+
# Errors from poll go to bridge.log; JSONL output appended to the queue file
|
|
133
|
+
if node "$POLL_JS" >> "$QUEUE_FILE" 2>>"${TRUEMATCH_DIR}/bridge.log"; then
|
|
134
|
+
# Process any queued messages
|
|
135
|
+
if [[ -s "$QUEUE_FILE" ]]; then
|
|
136
|
+
while IFS= read -r line; do
|
|
137
|
+
[[ -z "$line" ]] && continue
|
|
138
|
+
|
|
139
|
+
# Parse all fields in a single node invocation — uses sync readFileSync(0)
|
|
140
|
+
# so no async stdin buffering is needed. Fields are separated by ASCII 0x01
|
|
141
|
+
# (unit separator), which cannot appear in UTF-8 negotiation content.
|
|
142
|
+
parsed=$(echo "$line" | node -e "
|
|
143
|
+
try {
|
|
144
|
+
const m=JSON.parse(require('fs').readFileSync(0,'utf8'));
|
|
145
|
+
process.stdout.write([
|
|
146
|
+
m.thread_id||'',
|
|
147
|
+
m.peer_pubkey||'',
|
|
148
|
+
m.type||'negotiation',
|
|
149
|
+
m.content||'',
|
|
150
|
+
String(m.round_count??0)
|
|
151
|
+
].join('\x01')+'\n');
|
|
152
|
+
} catch(e) { process.stdout.write('\x01\x01\x01\x010\n'); }
|
|
153
|
+
")
|
|
154
|
+
IFS=$'\001' read -r thread_id peer_pubkey msg_type content round_count <<< "$parsed"
|
|
155
|
+
|
|
156
|
+
if [[ -n "$thread_id" ]]; then
|
|
157
|
+
# Save to thread state first
|
|
158
|
+
truematch match --status --thread "$thread_id" > /dev/null 2>&1 || true
|
|
159
|
+
process_message "$thread_id" "$peer_pubkey" "$msg_type" "$content" "$round_count"
|
|
160
|
+
fi
|
|
161
|
+
done < "$QUEUE_FILE"
|
|
162
|
+
|
|
163
|
+
# Clear the queue after processing
|
|
164
|
+
> "$QUEUE_FILE"
|
|
165
|
+
fi
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
sleep "$POLL_INTERVAL"
|
|
169
|
+
done
|