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.
@@ -0,0 +1,232 @@
1
+ import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { TRUEMATCH_DIR } from "./identity.js";
6
+ import { publishMessage } from "./nostr.js";
7
+ const THREADS_DIR = join(TRUEMATCH_DIR, "threads");
8
+ // Per spec: threads with no response expire after 72 hours
9
+ const THREAD_EXPIRY_MS = 72 * 60 * 60 * 1000;
10
+ // Maximum active threads allowed from a single unknown peer pubkey.
11
+ // Prevents disk exhaustion from arbitrary senders spamming new thread_ids.
12
+ const MAX_INBOUND_THREADS_PER_PEER = 3;
13
+ // Maximum rounds before hard termination
14
+ export const MAX_ROUNDS = 10;
15
+ async function ensureThreadsDir() {
16
+ if (!existsSync(THREADS_DIR)) {
17
+ await mkdir(THREADS_DIR, { recursive: true });
18
+ }
19
+ }
20
+ // UUID v4 pattern — all wire-supplied thread IDs must match before being used as filenames
21
+ 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}$/;
22
+ function threadFile(thread_id) {
23
+ if (!UUID_V4_RE.test(thread_id)) {
24
+ throw new Error(`Invalid thread_id format: ${thread_id}`);
25
+ }
26
+ return join(THREADS_DIR, `${thread_id}.json`);
27
+ }
28
+ export async function loadThread(thread_id) {
29
+ const path = threadFile(thread_id);
30
+ if (!existsSync(path))
31
+ return null;
32
+ const raw = await readFile(path, "utf8");
33
+ return JSON.parse(raw);
34
+ }
35
+ export async function saveThread(state) {
36
+ await ensureThreadsDir();
37
+ await writeFile(threadFile(state.thread_id), JSON.stringify(state, null, 2), {
38
+ encoding: "utf8",
39
+ mode: 0o600,
40
+ });
41
+ }
42
+ export async function listActiveThreads() {
43
+ await ensureThreadsDir();
44
+ const files = await readdir(THREADS_DIR);
45
+ const threads = [];
46
+ for (const f of files) {
47
+ if (!f.endsWith(".json"))
48
+ continue;
49
+ try {
50
+ const raw = await readFile(join(THREADS_DIR, f), "utf8");
51
+ const t = JSON.parse(raw);
52
+ if (t.status === "in_progress")
53
+ threads.push(t);
54
+ }
55
+ catch {
56
+ // Skip corrupted thread files rather than aborting the entire listing
57
+ }
58
+ }
59
+ return threads;
60
+ }
61
+ // Expire threads that have been silent for > 72 hours
62
+ export async function expireStaleThreads(nsec, relays) {
63
+ const active = await listActiveThreads();
64
+ for (const thread of active) {
65
+ if (Date.now() - new Date(thread.last_activity).getTime() >
66
+ THREAD_EXPIRY_MS) {
67
+ // Save before sending so a relay failure doesn't cause duplicate end messages on next cycle
68
+ thread.status = "expired";
69
+ thread.last_activity = new Date().toISOString();
70
+ await saveThread(thread);
71
+ await sendEnd(nsec, thread.peer_pubkey, thread.thread_id, relays);
72
+ }
73
+ }
74
+ }
75
+ // Create a new negotiation thread. Does NOT send an opening message —
76
+ // Claude writes and sends the opening via `truematch match --send`.
77
+ export async function initiateNegotiation(peerNpub) {
78
+ const thread_id = randomUUID();
79
+ const now = new Date().toISOString();
80
+ const state = {
81
+ thread_id,
82
+ peer_pubkey: peerNpub,
83
+ round_count: 0,
84
+ initiated_by_us: true,
85
+ we_proposed: false,
86
+ peer_proposed: false,
87
+ started_at: now,
88
+ last_activity: now,
89
+ status: "in_progress",
90
+ messages: [],
91
+ };
92
+ await saveThread(state);
93
+ return state;
94
+ }
95
+ // Save an incoming peer message to the thread
96
+ export async function receiveMessage(thread_id, peerNpub, content, type) {
97
+ // Validate thread_id from wire — reject silently to avoid leaking thread existence
98
+ if (!UUID_V4_RE.test(thread_id))
99
+ return null;
100
+ await ensureThreadsDir();
101
+ const now = new Date().toISOString();
102
+ let state = await loadThread(thread_id);
103
+ if (!state) {
104
+ // First message from this peer on this thread_id.
105
+ // Guard against DoS: count existing active threads from this peer.
106
+ const existing = await listActiveThreads();
107
+ const peerThreadCount = existing.filter((t) => t.peer_pubkey === peerNpub && !t.initiated_by_us).length;
108
+ if (peerThreadCount >= MAX_INBOUND_THREADS_PER_PEER)
109
+ return null;
110
+ // Create a new inbound thread
111
+ state = {
112
+ thread_id,
113
+ peer_pubkey: peerNpub,
114
+ round_count: 0,
115
+ initiated_by_us: false,
116
+ we_proposed: false,
117
+ peer_proposed: false,
118
+ started_at: now,
119
+ last_activity: now,
120
+ status: "in_progress",
121
+ messages: [],
122
+ };
123
+ }
124
+ else if (peerNpub !== state.peer_pubkey) {
125
+ // Reject messages from a different sender — return null (not state) to avoid
126
+ // leaking thread existence or peer identity to the caller
127
+ return null;
128
+ }
129
+ state.last_activity = now;
130
+ // round_count tracks only our outgoing messages — do not increment on receive
131
+ const incoming = {
132
+ role: "peer",
133
+ content,
134
+ timestamp: now,
135
+ };
136
+ state.messages.push(incoming);
137
+ if (type === "end") {
138
+ state.status = "declined";
139
+ }
140
+ else if (type === "match_propose") {
141
+ state.peer_proposed = true;
142
+ try {
143
+ const narrative = JSON.parse(content);
144
+ state.match_narrative = narrative;
145
+ }
146
+ catch {
147
+ // content was plain text; peer_proposed is still recorded
148
+ }
149
+ // Double-lock: if we already sent a proposal, both sides have now proposed → match confirmed
150
+ if (state.we_proposed) {
151
+ state.status = "matched";
152
+ }
153
+ }
154
+ await saveThread(state);
155
+ return state;
156
+ }
157
+ // Send a free-form negotiation message
158
+ export async function sendMessage(nsec, thread_id, content, relays) {
159
+ const state = await loadThread(thread_id);
160
+ if (!state)
161
+ throw new Error(`Thread ${thread_id} not found`);
162
+ if (state.status !== "in_progress") {
163
+ throw new Error(`Thread ${thread_id} is not in progress (status: ${state.status})`);
164
+ }
165
+ if (state.round_count >= MAX_ROUNDS) {
166
+ throw new Error(`Thread ${thread_id} has reached the ${MAX_ROUNDS}-round cap`);
167
+ }
168
+ const now = new Date().toISOString();
169
+ const msg = {
170
+ truematch: "2.0",
171
+ thread_id,
172
+ type: "negotiation",
173
+ timestamp: now,
174
+ content,
175
+ };
176
+ await publishMessage(nsec, state.peer_pubkey, msg, relays);
177
+ state.messages.push({ role: "us", content, timestamp: now });
178
+ state.round_count += 1;
179
+ state.last_activity = now;
180
+ await saveThread(state);
181
+ }
182
+ // Propose a match (double-lock: peer must also propose for match to confirm)
183
+ export async function proposeMatch(nsec, thread_id, narrative, relays) {
184
+ const state = await loadThread(thread_id);
185
+ if (!state)
186
+ throw new Error(`Thread ${thread_id} not found`);
187
+ if (state.status !== "in_progress") {
188
+ throw new Error(`Thread ${thread_id} is not in progress (status: ${state.status})`);
189
+ }
190
+ if (state.we_proposed)
191
+ throw new Error(`Already proposed on thread ${thread_id}`);
192
+ const now = new Date().toISOString();
193
+ const content = JSON.stringify(narrative);
194
+ const msg = {
195
+ truematch: "2.0",
196
+ thread_id,
197
+ type: "match_propose",
198
+ timestamp: now,
199
+ content,
200
+ };
201
+ await publishMessage(nsec, state.peer_pubkey, msg, relays);
202
+ state.messages.push({ role: "us", content, timestamp: now });
203
+ state.round_count += 1;
204
+ state.last_activity = now;
205
+ state.we_proposed = true;
206
+ // If peer already proposed, the match is confirmed (double-lock cleared)
207
+ if (state.peer_proposed) {
208
+ state.status = "matched";
209
+ }
210
+ await saveThread(state);
211
+ return state;
212
+ }
213
+ // Decline a match or end the negotiation
214
+ export async function declineMatch(nsec, thread_id, relays) {
215
+ const state = await loadThread(thread_id);
216
+ if (!state)
217
+ throw new Error(`Thread ${thread_id} not found`);
218
+ await sendEnd(nsec, state.peer_pubkey, thread_id, relays);
219
+ state.status = "declined";
220
+ state.last_activity = new Date().toISOString();
221
+ await saveThread(state);
222
+ }
223
+ // ── Private helpers ───────────────────────────────────────────────────────────
224
+ async function sendEnd(nsec, peerNpub, thread_id, relays) {
225
+ await publishMessage(nsec, peerNpub, {
226
+ truematch: "2.0",
227
+ thread_id,
228
+ type: "end",
229
+ timestamp: new Date().toISOString(),
230
+ content: "",
231
+ }, relays);
232
+ }
@@ -0,0 +1,5 @@
1
+ import type { TrueMatchMessage } from "./types.js";
2
+ export declare const DEFAULT_RELAYS: string[];
3
+ export declare function publishMessage(senderNsec: string, recipientNpub: string, message: TrueMatchMessage, relays?: string[]): Promise<void>;
4
+ export declare function subscribeToMessages(recipientNsec: string, recipientNpub: string, onMessage: (from: string, message: TrueMatchMessage) => Promise<void>, relays?: string[], since?: number, onEose?: () => void): Promise<() => void>;
5
+ export declare function checkRelayConnectivity(relays?: string[]): Promise<Record<string, boolean>>;
package/dist/nostr.js ADDED
@@ -0,0 +1,117 @@
1
+ import { SimplePool, finalizeEvent, verifyEvent, } from "nostr-tools";
2
+ import { nip04 } from "nostr-tools";
3
+ import { hexToBytes } from "nostr-tools/utils";
4
+ // Public Nostr relays — agents must publish to ≥ 2 relays per spec
5
+ export const DEFAULT_RELAYS = [
6
+ "wss://relay.damus.io",
7
+ "wss://nos.lol",
8
+ "wss://relay.nostr.band",
9
+ "wss://nostr.mom",
10
+ ];
11
+ // NIP-04 kind for encrypted DMs.
12
+ // NOTE: NIP-04 is deprecated by the Nostr protocol in favour of NIP-17 (gift-wrapped DMs).
13
+ // NIP-17 hides sender, recipient, and timestamp from relay operators. A future version of
14
+ // TrueMatch should migrate to NIP-17 / NIP-59 for stronger metadata privacy.
15
+ const KIND_ENCRYPTED_DM = 4;
16
+ function encryptMessage(senderNsec, recipientNpub, message) {
17
+ const plaintext = JSON.stringify(message);
18
+ return nip04.encrypt(senderNsec, recipientNpub, plaintext);
19
+ }
20
+ function decryptMessage(recipientNsec, senderNpub, ciphertext) {
21
+ const plaintext = nip04.decrypt(recipientNsec, senderNpub, ciphertext);
22
+ return JSON.parse(plaintext);
23
+ }
24
+ export async function publishMessage(senderNsec, recipientNpub, message, relays = DEFAULT_RELAYS) {
25
+ const ciphertext = encryptMessage(senderNsec, recipientNpub, message);
26
+ const eventTemplate = {
27
+ kind: KIND_ENCRYPTED_DM,
28
+ created_at: Math.floor(Date.now() / 1000),
29
+ tags: [["p", recipientNpub]],
30
+ content: ciphertext,
31
+ };
32
+ const secretKeyBytes = hexToBytes(senderNsec);
33
+ const signedEvent = finalizeEvent(eventTemplate, secretKeyBytes);
34
+ const pool = new SimplePool();
35
+ try {
36
+ const results = await Promise.allSettled(pool.publish(relays, signedEvent));
37
+ const succeeded = results.filter((r) => r.status === "fulfilled").length;
38
+ if (succeeded === 0) {
39
+ throw new Error("Failed to publish to any relay");
40
+ }
41
+ }
42
+ finally {
43
+ pool.close(relays);
44
+ }
45
+ }
46
+ // Maximum number of event IDs to keep in the deduplication set for long-running subscriptions.
47
+ // When exceeded, the set is cleared (a handful of relayed duplicates may slip through briefly).
48
+ const MAX_SEEN_IDS = 1000;
49
+ export async function subscribeToMessages(recipientNsec, recipientNpub, onMessage, relays = DEFAULT_RELAYS, since, onEose) {
50
+ const pool = new SimplePool();
51
+ // Deduplicate events delivered by multiple relays (bounded to prevent unbounded growth)
52
+ const seenEventIds = new Set();
53
+ const sub = pool.subscribeMany(relays, {
54
+ kinds: [KIND_ENCRYPTED_DM],
55
+ "#p": [recipientNpub],
56
+ since: since ?? Math.floor(Date.now() / 1000) - 60 * 60, // last hour
57
+ }, {
58
+ onevent: async (event) => {
59
+ // NIP-01: verify event signature before processing
60
+ if (!verifyEvent(event))
61
+ return;
62
+ // Skip duplicates (same event from multiple relays)
63
+ if (seenEventIds.has(event.id))
64
+ return;
65
+ // Bound the deduplication set to prevent unbounded memory growth
66
+ if (seenEventIds.size >= MAX_SEEN_IDS)
67
+ seenEventIds.clear();
68
+ seenEventIds.add(event.id);
69
+ const senderNpub = event.pubkey;
70
+ try {
71
+ const message = decryptMessage(recipientNsec, senderNpub, event.content);
72
+ // Only process TrueMatch protocol messages
73
+ if (typeof message === "object" &&
74
+ message !== null &&
75
+ "truematch" in message &&
76
+ message.truematch === "2.0") {
77
+ await onMessage(senderNpub, message);
78
+ }
79
+ }
80
+ catch {
81
+ // Ignore messages that fail to decrypt or parse
82
+ }
83
+ },
84
+ oneose: () => {
85
+ // Historical replay complete — live events follow from here
86
+ onEose?.();
87
+ },
88
+ });
89
+ return () => {
90
+ sub.close();
91
+ pool.close(relays);
92
+ };
93
+ }
94
+ export async function checkRelayConnectivity(relays = DEFAULT_RELAYS) {
95
+ const results = {};
96
+ await Promise.all(relays.map(async (relay) => {
97
+ try {
98
+ const ws = new WebSocket(relay);
99
+ await new Promise((resolve, reject) => {
100
+ ws.onopen = () => {
101
+ ws.close();
102
+ resolve();
103
+ };
104
+ ws.onerror = () => reject(new Error("connection failed"));
105
+ setTimeout(() => {
106
+ ws.close();
107
+ reject(new Error("timeout"));
108
+ }, 5000);
109
+ });
110
+ results[relay] = true;
111
+ }
112
+ catch {
113
+ results[relay] = false;
114
+ }
115
+ }));
116
+ return results;
117
+ }
@@ -0,0 +1,19 @@
1
+ import type { ObservationSummary } from "./types.js";
2
+ export declare const DIMENSION_FLOORS: {
3
+ readonly attachment: 0.55;
4
+ readonly core_values: 0.55;
5
+ readonly communication: 0.55;
6
+ readonly emotional_regulation: 0.6;
7
+ readonly humor: 0.5;
8
+ readonly life_velocity: 0.5;
9
+ readonly dealbreakers: 0.6;
10
+ readonly conflict_resolution: 0.55;
11
+ readonly interdependence_model: 0.5;
12
+ };
13
+ export declare const ELIGIBILITY_FRESHNESS_HOURS = 72;
14
+ export declare function loadObservation(): Promise<ObservationSummary | null>;
15
+ export declare function saveObservation(obs: ObservationSummary): Promise<void>;
16
+ export declare function isEligible(obs: ObservationSummary): boolean;
17
+ export declare function isStale(obs: ObservationSummary): boolean;
18
+ export declare function emptyObservation(): ObservationSummary;
19
+ export declare function eligibilityReport(obs: ObservationSummary): string;
@@ -0,0 +1,136 @@
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 OBSERVATION_FILE = join(TRUEMATCH_DIR, "observation.json");
6
+ // Global minimums — cross-session sanity check
7
+ const GLOBAL_MIN_CONVERSATIONS = 2;
8
+ const GLOBAL_MIN_DAYS = 2;
9
+ // Per-dimension confidence floors (psychologist-derived)
10
+ // attachment/emotional_regulation: high contextual sensitivity → higher floor
11
+ // dealbreakers: can surface in a single conversation → higher floor, no day req
12
+ // communication: 0.55 (Knapp et al. — equal predictive weight to attachment)
13
+ // conflict_resolution: 0.55 (Gottman Four Horsemen — distinct from emotional_regulation)
14
+ // interdependence_model: 0.50 (Baxter & Montgomery — connection-autonomy dialectic)
15
+ export const DIMENSION_FLOORS = {
16
+ attachment: 0.55,
17
+ core_values: 0.55,
18
+ communication: 0.55,
19
+ emotional_regulation: 0.6,
20
+ humor: 0.5,
21
+ life_velocity: 0.5,
22
+ dealbreakers: 0.6,
23
+ conflict_resolution: 0.55,
24
+ interdependence_model: 0.5,
25
+ };
26
+ // Manifest is stale if eligibility was last computed more than this many hours ago.
27
+ // Bridge should trigger re-synthesis if stale.
28
+ export const ELIGIBILITY_FRESHNESS_HOURS = 72;
29
+ export async function loadObservation() {
30
+ if (!existsSync(OBSERVATION_FILE))
31
+ return null;
32
+ const raw = await readFile(OBSERVATION_FILE, "utf8");
33
+ return JSON.parse(raw);
34
+ }
35
+ export async function saveObservation(obs) {
36
+ const now = new Date().toISOString();
37
+ const updated = {
38
+ ...obs,
39
+ updated_at: now,
40
+ eligibility_computed_at: now,
41
+ matching_eligible: isEligible(obs),
42
+ };
43
+ await writeFile(OBSERVATION_FILE, JSON.stringify(updated, null, 2), "utf8");
44
+ }
45
+ export function isEligible(obs) {
46
+ if (obs.conversation_count < GLOBAL_MIN_CONVERSATIONS)
47
+ return false;
48
+ if (obs.observation_span_days < GLOBAL_MIN_DAYS)
49
+ return false;
50
+ if (obs.dealbreaker_gate_state === "below_floor")
51
+ return false;
52
+ if (obs.dealbreaker_gate_state === "none_observed")
53
+ return false;
54
+ return (obs.attachment.confidence >= DIMENSION_FLOORS.attachment &&
55
+ obs.core_values.confidence >= DIMENSION_FLOORS.core_values &&
56
+ obs.communication.confidence >= DIMENSION_FLOORS.communication &&
57
+ obs.emotional_regulation.confidence >=
58
+ DIMENSION_FLOORS.emotional_regulation &&
59
+ obs.humor.confidence >= DIMENSION_FLOORS.humor &&
60
+ obs.life_velocity.confidence >= DIMENSION_FLOORS.life_velocity &&
61
+ obs.dealbreakers.confidence >= DIMENSION_FLOORS.dealbreakers &&
62
+ obs.conflict_resolution.confidence >=
63
+ DIMENSION_FLOORS.conflict_resolution &&
64
+ obs.interdependence_model.confidence >=
65
+ DIMENSION_FLOORS.interdependence_model);
66
+ }
67
+ export function isStale(obs) {
68
+ const computedAt = new Date(obs.eligibility_computed_at).getTime();
69
+ return Date.now() - computedAt > ELIGIBILITY_FRESHNESS_HOURS * 60 * 60 * 1000;
70
+ }
71
+ export function emptyObservation() {
72
+ const now = new Date().toISOString();
73
+ const emptyDim = {
74
+ confidence: 0,
75
+ observation_count: 0,
76
+ behavioral_context_diversity: "low",
77
+ };
78
+ return {
79
+ updated_at: now,
80
+ eligibility_computed_at: now,
81
+ matching_eligible: false,
82
+ conversation_count: 0,
83
+ observation_span_days: 0,
84
+ attachment: { ...emptyDim },
85
+ core_values: { ...emptyDim },
86
+ communication: { ...emptyDim },
87
+ emotional_regulation: { ...emptyDim },
88
+ humor: { ...emptyDim },
89
+ life_velocity: { ...emptyDim },
90
+ dealbreakers: { ...emptyDim },
91
+ conflict_resolution: { ...emptyDim },
92
+ interdependence_model: { ...emptyDim },
93
+ dealbreaker_gate_state: "none_observed",
94
+ inferred_intent_category: "unclear",
95
+ };
96
+ }
97
+ export function eligibilityReport(obs) {
98
+ const lines = [];
99
+ const pass = (label, ok, detail) => lines.push(`${ok ? "✓" : "✗"} ${label}: ${detail}`);
100
+ pass("Conversations", obs.conversation_count >= GLOBAL_MIN_CONVERSATIONS, `${obs.conversation_count} / ${GLOBAL_MIN_CONVERSATIONS} required`);
101
+ pass("Observation span", obs.observation_span_days >= GLOBAL_MIN_DAYS, `${obs.observation_span_days} days / ${GLOBAL_MIN_DAYS} required`);
102
+ pass("Dealbreaker gate", obs.dealbreaker_gate_state !== "below_floor" &&
103
+ obs.dealbreaker_gate_state !== "none_observed", obs.dealbreaker_gate_state);
104
+ const dims = [
105
+ ["Attachment", obs.attachment, DIMENSION_FLOORS.attachment],
106
+ ["Core values", obs.core_values, DIMENSION_FLOORS.core_values],
107
+ ["Communication", obs.communication, DIMENSION_FLOORS.communication],
108
+ [
109
+ "Emotional regulation",
110
+ obs.emotional_regulation,
111
+ DIMENSION_FLOORS.emotional_regulation,
112
+ ],
113
+ ["Humor", obs.humor, DIMENSION_FLOORS.humor],
114
+ ["Life velocity", obs.life_velocity, DIMENSION_FLOORS.life_velocity],
115
+ ["Dealbreakers", obs.dealbreakers, DIMENSION_FLOORS.dealbreakers],
116
+ [
117
+ "Conflict resolution",
118
+ obs.conflict_resolution,
119
+ DIMENSION_FLOORS.conflict_resolution,
120
+ ],
121
+ [
122
+ "Interdependence model",
123
+ obs.interdependence_model,
124
+ DIMENSION_FLOORS.interdependence_model,
125
+ ],
126
+ ];
127
+ for (const [name, dim, floor] of dims) {
128
+ const diversity = dim.behavioral_context_diversity !== "low" ? "" : " [low diversity]";
129
+ pass(name, dim.confidence >= floor, `confidence ${dim.confidence.toFixed(2)} / ${floor.toFixed(2)} required (${dim.observation_count} signals)${diversity}`);
130
+ }
131
+ const stale = isStale(obs);
132
+ if (stale) {
133
+ lines.push(`⚠ Manifest stale — last computed ${obs.eligibility_computed_at}. Run: truematch observe --update`);
134
+ }
135
+ return lines.join("\n");
136
+ }
@@ -0,0 +1,31 @@
1
+ interface PluginEvent {
2
+ type: string;
3
+ action: string;
4
+ messages: string[];
5
+ }
6
+ interface PluginHookBeforePromptBuildResult {
7
+ prependContext?: string;
8
+ systemPrompt?: string;
9
+ }
10
+ interface PluginAPI {
11
+ on(event: "before_prompt_build", handler: (event: PluginEvent) => PluginHookBeforePromptBuildResult | void | Promise<PluginHookBeforePromptBuildResult | void>): void;
12
+ on(event: "session_start" | "session_end", handler: (event: PluginEvent) => void | Promise<void>): void;
13
+ registerHook(event: string, handler: (event: PluginEvent) => void, meta?: {
14
+ name?: string;
15
+ description?: string;
16
+ }): void;
17
+ registerTool(tool: {
18
+ name: string;
19
+ description: string;
20
+ handler: (rawArgs: string) => string;
21
+ }): void;
22
+ }
23
+ declare const _default: {
24
+ id: string;
25
+ name: string;
26
+ description: string;
27
+ version: string;
28
+ kind: string;
29
+ register(api: PluginAPI): void;
30
+ };
31
+ export default _default;