pulse-protocol 0.9.3

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/cli.js ADDED
@@ -0,0 +1,970 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { Command } from "commander";
5
+ import chalk3 from "chalk";
6
+ import ora from "ora";
7
+ import * as fs2 from "fs";
8
+ import * as path from "path";
9
+ import * as os from "os";
10
+
11
+ // src/core/client.ts
12
+ import { EventEmitter } from "eventemitter3";
13
+
14
+ // src/core/errors.ts
15
+ var PulseError = class _PulseError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = "PulseError";
19
+ Object.setPrototypeOf(this, _PulseError.prototype);
20
+ }
21
+ };
22
+ var ScanError = class _ScanError extends PulseError {
23
+ detector;
24
+ constructor(message, detector) {
25
+ super(message);
26
+ this.name = "ScanError";
27
+ this.detector = detector;
28
+ Object.setPrototypeOf(this, _ScanError.prototype);
29
+ }
30
+ };
31
+ var SourceError = class _SourceError extends PulseError {
32
+ source;
33
+ status;
34
+ constructor(source, message, status) {
35
+ super(`${source}: ${message}`);
36
+ this.name = "SourceError";
37
+ this.source = source;
38
+ this.status = status;
39
+ Object.setPrototypeOf(this, _SourceError.prototype);
40
+ }
41
+ };
42
+
43
+ // src/sources/helius.ts
44
+ var HeliusSource = class {
45
+ baseUrl;
46
+ apiKey;
47
+ timeout;
48
+ maxRetries;
49
+ constructor(config) {
50
+ this.apiKey = config.apiKey;
51
+ this.baseUrl = config.rpcUrl ?? "https://api.helius.xyz/v0";
52
+ this.timeout = config.timeout;
53
+ this.maxRetries = config.maxRetries;
54
+ }
55
+ async getWalletHistory(wallet, opts = {}) {
56
+ const limit = opts.limit ?? 100;
57
+ const url = new URL(`${this.baseUrl}/addresses/${wallet}/transactions`);
58
+ url.searchParams.set("api-key", this.apiKey);
59
+ url.searchParams.set("limit", String(Math.min(limit, 100)));
60
+ if (opts.before) url.searchParams.set("before", opts.before);
61
+ const raw = await this.fetchWithRetry(url);
62
+ if (!Array.isArray(raw)) {
63
+ throw new SourceError("helius", "unexpected response shape");
64
+ }
65
+ return raw.map((tx) => {
66
+ const t = tx;
67
+ return {
68
+ signature: String(t.signature ?? ""),
69
+ timestamp: Number(t.timestamp ?? 0),
70
+ fee: Number(t.fee ?? 0),
71
+ programs: Array.isArray(t.accountData) ? extractPrograms(t) : [],
72
+ type: String(t.type ?? "UNKNOWN")
73
+ };
74
+ });
75
+ }
76
+ async fetchWithRetry(url) {
77
+ let lastError;
78
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
79
+ try {
80
+ const controller = new AbortController();
81
+ const timer = setTimeout(() => controller.abort(), this.timeout);
82
+ const res = await fetch(url, { signal: controller.signal });
83
+ clearTimeout(timer);
84
+ if (res.status === 429) {
85
+ const retryAfter = Number(res.headers.get("retry-after") ?? "2");
86
+ await sleep(retryAfter * 1e3);
87
+ continue;
88
+ }
89
+ if (!res.ok) {
90
+ throw new SourceError(
91
+ "helius",
92
+ `HTTP ${res.status}`,
93
+ res.status
94
+ );
95
+ }
96
+ return await res.json();
97
+ } catch (err) {
98
+ lastError = err;
99
+ if (attempt < this.maxRetries) {
100
+ await sleep(500 * (attempt + 1));
101
+ }
102
+ }
103
+ }
104
+ throw lastError instanceof Error ? new SourceError("helius", lastError.message) : new SourceError("helius", "unknown error");
105
+ }
106
+ };
107
+ function extractPrograms(tx) {
108
+ const programs = /* @__PURE__ */ new Set();
109
+ for (const ix of tx.instructions ?? []) {
110
+ if (ix.programId) programs.add(ix.programId);
111
+ }
112
+ return Array.from(programs);
113
+ }
114
+ function sleep(ms) {
115
+ return new Promise((r) => setTimeout(r, ms));
116
+ }
117
+
118
+ // src/sources/x.ts
119
+ var XSource = class {
120
+ baseUrl;
121
+ apiKey;
122
+ timeout;
123
+ maxRetries;
124
+ constructor(config) {
125
+ this.apiKey = config.apiKey;
126
+ this.baseUrl = config.baseUrl ?? "https://api.twitterapi.io";
127
+ this.timeout = config.timeout;
128
+ this.maxRetries = config.maxRetries;
129
+ }
130
+ async getRecentPosts(handle, opts = {}) {
131
+ const limit = opts.limit ?? 100;
132
+ const url = new URL(`${this.baseUrl}/twitter/user/last_tweets`);
133
+ url.searchParams.set("userName", handle);
134
+ const raw = await this.fetchWithRetry(url);
135
+ const tweets = Array.isArray(raw.tweets) ? raw.tweets : Array.isArray(raw.data?.tweets) ? raw.data.tweets : [];
136
+ const posts = [];
137
+ for (const t of tweets.slice(0, limit)) {
138
+ const obj = t;
139
+ const createdAt = parseDate(obj.createdAt);
140
+ if (!createdAt) continue;
141
+ posts.push({
142
+ id: String(obj.id ?? ""),
143
+ text: String(obj.text ?? ""),
144
+ created_at: createdAt,
145
+ reply_count: Number(obj.replyCount ?? 0),
146
+ like_count: Number(obj.likeCount ?? 0),
147
+ retweet_count: Number(obj.retweetCount ?? 0),
148
+ in_reply_to_id: obj.inReplyToId ? String(obj.inReplyToId) : void 0
149
+ });
150
+ }
151
+ for (let i = 0; i < posts.length; i++) {
152
+ const p = posts[i];
153
+ if (p.in_reply_to_id) {
154
+ const parent = posts.find((x) => x.id === p.in_reply_to_id);
155
+ if (parent) {
156
+ p.reply_delta_ms = (p.created_at - parent.created_at) * 1e3;
157
+ }
158
+ }
159
+ }
160
+ return posts;
161
+ }
162
+ async fetchWithRetry(url) {
163
+ let lastError;
164
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
165
+ try {
166
+ const controller = new AbortController();
167
+ const timer = setTimeout(() => controller.abort(), this.timeout);
168
+ const res = await fetch(url, {
169
+ signal: controller.signal,
170
+ headers: { "X-API-Key": this.apiKey }
171
+ });
172
+ clearTimeout(timer);
173
+ if (res.status === 429) {
174
+ const retryAfter = Number(res.headers.get("retry-after") ?? "2");
175
+ await sleep2(retryAfter * 1e3);
176
+ continue;
177
+ }
178
+ if (!res.ok) {
179
+ throw new SourceError("x", `HTTP ${res.status}`, res.status);
180
+ }
181
+ return await res.json();
182
+ } catch (err) {
183
+ lastError = err;
184
+ if (attempt < this.maxRetries) {
185
+ await sleep2(500 * (attempt + 1));
186
+ }
187
+ }
188
+ }
189
+ throw lastError instanceof Error ? new SourceError("x", lastError.message) : new SourceError("x", "unknown error");
190
+ }
191
+ };
192
+ function parseDate(v) {
193
+ if (typeof v === "number") return v;
194
+ if (typeof v !== "string") return null;
195
+ const parsed = Date.parse(v);
196
+ return isNaN(parsed) ? null : Math.floor(parsed / 1e3);
197
+ }
198
+ function sleep2(ms) {
199
+ return new Promise((r) => setTimeout(r, ms));
200
+ }
201
+
202
+ // src/detectors/cadence.ts
203
+ function cadenceDetector(posts) {
204
+ const notes = [];
205
+ if (posts.length < 20) {
206
+ notes.push(`only ${posts.length} posts available, low confidence`);
207
+ return {
208
+ detector: "cadence",
209
+ score: 50,
210
+ samples: posts.length,
211
+ notes,
212
+ runtime_ms: 0
213
+ };
214
+ }
215
+ const hourBuckets = new Array(24).fill(0);
216
+ for (const p of posts) {
217
+ const hour = new Date(p.created_at * 1e3).getUTCHours();
218
+ hourBuckets[hour]++;
219
+ }
220
+ const total = posts.length;
221
+ let entropy = 0;
222
+ for (const count of hourBuckets) {
223
+ if (count > 0) {
224
+ const p = count / total;
225
+ entropy -= p * Math.log2(p);
226
+ }
227
+ }
228
+ const entropyScore = entropy / Math.log2(24) * 100;
229
+ notes.push(`hour entropy: ${entropy.toFixed(2)} bits (${entropyScore.toFixed(0)}/100)`);
230
+ const businessHours = hourBuckets.slice(9, 18).reduce((a, b) => a + b, 0);
231
+ const businessRatio = businessHours / total;
232
+ notes.push(`business-hours ratio: ${(businessRatio * 100).toFixed(1)}%`);
233
+ const sleepWindow = hourBuckets.slice(4, 8).reduce((a, b) => a + b, 0);
234
+ const sleepRatio = sleepWindow / total;
235
+ const hasDeadZone = sleepRatio < 0.05;
236
+ notes.push(
237
+ hasDeadZone ? `sleep window detected (${(sleepRatio * 100).toFixed(1)}% posts in 4-8am)` : `no sleep window (${(sleepRatio * 100).toFixed(1)}% posts in 4-8am)`
238
+ );
239
+ const sorted = [...posts].sort((a, b) => a.created_at - b.created_at);
240
+ const intervals = [];
241
+ for (let i = 1; i < sorted.length; i++) {
242
+ intervals.push(sorted[i].created_at - sorted[i - 1].created_at);
243
+ }
244
+ const meanInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
245
+ const variance = intervals.reduce((a, b) => a + (b - meanInterval) ** 2, 0) / intervals.length;
246
+ const cv = Math.sqrt(variance) / meanInterval;
247
+ notes.push(`interval CV: ${cv.toFixed(2)} (lower = more regular = agent-like)`);
248
+ let score = 0;
249
+ score += entropyScore * 0.4;
250
+ score += (1 - Math.abs(businessRatio - 0.15)) * 100 * 0.25;
251
+ score += (hasDeadZone ? 0 : 100) * 0.2;
252
+ score += Math.max(0, 100 - cv * 30) * 0.15;
253
+ return {
254
+ detector: "cadence",
255
+ score: Math.round(Math.max(0, Math.min(100, score))),
256
+ samples: posts.length,
257
+ notes,
258
+ runtime_ms: 0
259
+ };
260
+ }
261
+
262
+ // src/detectors/onchain.ts
263
+ function onchainDetector(txs) {
264
+ const notes = [];
265
+ if (txs.length < 10) {
266
+ notes.push(`only ${txs.length} transactions, low confidence`);
267
+ return {
268
+ detector: "onchain",
269
+ score: 50,
270
+ samples: txs.length,
271
+ notes,
272
+ runtime_ms: 0
273
+ };
274
+ }
275
+ const hourBuckets = new Array(24).fill(0);
276
+ for (const t of txs) {
277
+ const hour = new Date(t.timestamp * 1e3).getUTCHours();
278
+ hourBuckets[hour]++;
279
+ }
280
+ let entropy = 0;
281
+ for (const count of hourBuckets) {
282
+ if (count > 0) {
283
+ const p = count / txs.length;
284
+ entropy -= p * Math.log2(p);
285
+ }
286
+ }
287
+ const entropyScore = entropy / Math.log2(24) * 100;
288
+ notes.push(`onchain hour entropy: ${entropy.toFixed(2)} bits`);
289
+ const sorted = [...txs].sort((a, b) => a.timestamp - b.timestamp);
290
+ let bursts = 0;
291
+ for (let i = 0; i < sorted.length - 3; i++) {
292
+ const window = sorted[i + 3].timestamp - sorted[i].timestamp;
293
+ if (window <= 10) bursts++;
294
+ }
295
+ const burstRatio = bursts / Math.max(1, sorted.length - 3);
296
+ notes.push(`burst clusters: ${bursts} (${(burstRatio * 100).toFixed(1)}% of windows)`);
297
+ const programs = /* @__PURE__ */ new Set();
298
+ for (const t of txs) {
299
+ for (const p of t.programs ?? []) {
300
+ programs.add(p);
301
+ }
302
+ }
303
+ const diversityScore = Math.min(100, programs.size * 10);
304
+ notes.push(`${programs.size} unique programs touched`);
305
+ const fees = txs.map((t) => t.fee ?? 5e3).filter((f) => f > 0);
306
+ const meanFee = fees.reduce((a, b) => a + b, 0) / Math.max(1, fees.length);
307
+ const feeStd = Math.sqrt(
308
+ fees.reduce((a, b) => a + (b - meanFee) ** 2, 0) / Math.max(1, fees.length)
309
+ );
310
+ const feeCV = meanFee > 0 ? feeStd / meanFee : 0;
311
+ notes.push(`fee CV: ${feeCV.toFixed(2)}`);
312
+ let score = 0;
313
+ score += entropyScore * 0.35;
314
+ score += Math.min(100, burstRatio * 300) * 0.3;
315
+ score += (100 - diversityScore) * 0.2;
316
+ score += Math.max(0, 100 - feeCV * 50) * 0.15;
317
+ return {
318
+ detector: "onchain",
319
+ score: Math.round(Math.max(0, Math.min(100, score))),
320
+ samples: txs.length,
321
+ notes,
322
+ runtime_ms: 0
323
+ };
324
+ }
325
+
326
+ // src/detectors/voice.ts
327
+ function voiceDetector(posts) {
328
+ const notes = [];
329
+ if (posts.length < 15) {
330
+ notes.push(`only ${posts.length} posts, low confidence`);
331
+ return {
332
+ detector: "voice",
333
+ score: 50,
334
+ samples: posts.length,
335
+ notes,
336
+ runtime_ms: 0
337
+ };
338
+ }
339
+ const allTokens = [];
340
+ for (const p of posts) {
341
+ const tokens = tokenize(p.text);
342
+ allTokens.push(...tokens);
343
+ }
344
+ const uniqueRatio = new Set(allTokens).size / allTokens.length;
345
+ notes.push(`lexical diversity: ${uniqueRatio.toFixed(3)}`);
346
+ let typoCount = 0;
347
+ for (const p of posts) {
348
+ if (/([a-z])\1{2,}/i.test(p.text)) typoCount++;
349
+ if (/\b[bcdfghjklmnpqrstvwxyz]{4,}\b/i.test(p.text)) typoCount++;
350
+ }
351
+ const typoRate = typoCount / posts.length;
352
+ notes.push(`typo rate: ${(typoRate * 100).toFixed(1)}%`);
353
+ const trigrams = /* @__PURE__ */ new Map();
354
+ for (const p of posts) {
355
+ const tokens = tokenize(p.text);
356
+ for (let i = 0; i < tokens.length - 2; i++) {
357
+ const key = `${tokens[i]} ${tokens[i + 1]} ${tokens[i + 2]}`;
358
+ trigrams.set(key, (trigrams.get(key) ?? 0) + 1);
359
+ }
360
+ }
361
+ const repeated = Array.from(trigrams.values()).filter((v) => v > 1).length;
362
+ const repetitionRate = repeated / Math.max(1, trigrams.size);
363
+ notes.push(`phrase repetition: ${(repetitionRate * 100).toFixed(1)}%`);
364
+ const emojiRegex = /[\p{Emoji_Presentation}]/u;
365
+ const withEmoji = posts.filter((p) => emojiRegex.test(p.text)).length;
366
+ const emojiRatio = withEmoji / posts.length;
367
+ const emojiConsistency = Math.abs(emojiRatio - 0.5) * 2;
368
+ notes.push(`emoji ratio: ${(emojiRatio * 100).toFixed(0)}%`);
369
+ let score = 0;
370
+ score += (1 - uniqueRatio) * 100 * 0.35;
371
+ score += (1 - typoRate) * 100 * 0.25;
372
+ score += repetitionRate * 100 * 0.2;
373
+ score += emojiConsistency * 100 * 0.2;
374
+ return {
375
+ detector: "voice",
376
+ score: Math.round(Math.max(0, Math.min(100, score))),
377
+ samples: posts.length,
378
+ notes,
379
+ runtime_ms: 0
380
+ };
381
+ }
382
+ function tokenize(text) {
383
+ return text.toLowerCase().replace(/https?:\/\/\S+/g, "").replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((t) => t.length > 1);
384
+ }
385
+
386
+ // src/detectors/timing.ts
387
+ function timingDetector(posts, txs) {
388
+ const notes = [];
389
+ let anomalies = 0;
390
+ const quickReplies = posts.filter(
391
+ (p) => p.in_reply_to_id && p.reply_delta_ms !== void 0 && p.reply_delta_ms < 1e3
392
+ ).length;
393
+ if (quickReplies > 0) {
394
+ anomalies += Math.min(3, quickReplies);
395
+ notes.push(`${quickReplies} sub-second reply posts`);
396
+ }
397
+ const sortedPosts = [...posts].sort((a, b) => a.created_at - b.created_at);
398
+ let regular = 0;
399
+ for (let i = 2; i < sortedPosts.length; i++) {
400
+ const d1 = sortedPosts[i].created_at - sortedPosts[i - 1].created_at;
401
+ const d2 = sortedPosts[i - 1].created_at - sortedPosts[i - 2].created_at;
402
+ if (Math.abs(d1 - d2) < 5 && d1 > 0) regular++;
403
+ }
404
+ if (regular >= 5) {
405
+ anomalies++;
406
+ notes.push(`${regular} posts with cron-like regularity`);
407
+ }
408
+ let simultaneous = 0;
409
+ const txTimes = new Set(txs.map((t) => t.timestamp));
410
+ for (const p of posts) {
411
+ for (let offset = -1; offset <= 1; offset++) {
412
+ if (txTimes.has(p.created_at + offset)) {
413
+ simultaneous++;
414
+ break;
415
+ }
416
+ }
417
+ }
418
+ if (simultaneous >= 3) {
419
+ anomalies++;
420
+ notes.push(`${simultaneous} simultaneous X+onchain events (within 1s)`);
421
+ }
422
+ const deadHour = posts.filter((p) => {
423
+ const h = new Date(p.created_at * 1e3).getUTCHours();
424
+ return h >= 4 && h < 7;
425
+ }).length;
426
+ if (deadHour / posts.length > 0.1) {
427
+ anomalies++;
428
+ notes.push(`${deadHour} posts during 4-7am UTC dead zone`);
429
+ }
430
+ let burstAfterSilence = 0;
431
+ for (let i = 1; i < sortedPosts.length; i++) {
432
+ const gap = sortedPosts[i].created_at - sortedPosts[i - 1].created_at;
433
+ if (gap > 48 * 3600) {
434
+ const nextFive = sortedPosts.slice(i, i + 5);
435
+ if (nextFive.length === 5) {
436
+ const span = nextFive[4].created_at - nextFive[0].created_at;
437
+ if (span < 600) burstAfterSilence++;
438
+ }
439
+ }
440
+ }
441
+ if (burstAfterSilence > 0) {
442
+ anomalies += burstAfterSilence;
443
+ notes.push(`${burstAfterSilence} silence-then-burst patterns`);
444
+ }
445
+ if (anomalies === 0) {
446
+ notes.push("no timing anomalies detected");
447
+ }
448
+ return {
449
+ detector: "timing",
450
+ score: anomalies,
451
+ // raw anomaly count, aggregator normalizes
452
+ samples: posts.length + txs.length,
453
+ notes,
454
+ runtime_ms: 0
455
+ };
456
+ }
457
+
458
+ // src/detectors/correlation.ts
459
+ function correlationDetector(posts, txs, target) {
460
+ const notes = [];
461
+ if (posts.length < 10 || txs.length < 10) {
462
+ notes.push(`insufficient data (posts=${posts.length}, txs=${txs.length})`);
463
+ return {
464
+ detector: "correlation",
465
+ score: 50,
466
+ samples: posts.length + txs.length,
467
+ notes,
468
+ runtime_ms: 0
469
+ };
470
+ }
471
+ const postsByHour = /* @__PURE__ */ new Map();
472
+ const txsByHour = /* @__PURE__ */ new Map();
473
+ const bucket = (t) => Math.floor(t / 3600);
474
+ for (const p of posts) {
475
+ const b = bucket(p.created_at);
476
+ postsByHour.set(b, (postsByHour.get(b) ?? 0) + 1);
477
+ }
478
+ for (const t of txs) {
479
+ const b = bucket(t.timestamp);
480
+ txsByHour.set(b, (txsByHour.get(b) ?? 0) + 1);
481
+ }
482
+ const allHours = /* @__PURE__ */ new Set([...postsByHour.keys(), ...txsByHour.keys()]);
483
+ const postVec = [];
484
+ const txVec = [];
485
+ for (const h of allHours) {
486
+ postVec.push(postsByHour.get(h) ?? 0);
487
+ txVec.push(txsByHour.get(h) ?? 0);
488
+ }
489
+ const pearson = pearsonCorrelation(postVec, txVec);
490
+ notes.push(`pearson r = ${pearson.toFixed(3)}`);
491
+ const postSpan = maxOf(posts.map((p) => p.created_at)) - minOf(posts.map((p) => p.created_at));
492
+ const txSpan = maxOf(txs.map((t) => t.timestamp)) - minOf(txs.map((t) => t.timestamp));
493
+ const postStart = minOf(posts.map((p) => p.created_at));
494
+ const txStart = minOf(txs.map((t) => t.timestamp));
495
+ const overlapStart = Math.max(postStart, txStart);
496
+ const overlapEnd = Math.min(postStart + postSpan, txStart + txSpan);
497
+ const overlap = Math.max(0, overlapEnd - overlapStart);
498
+ const overlapRatio = overlap / Math.max(postSpan, txSpan, 1);
499
+ notes.push(`timeline overlap: ${(overlapRatio * 100).toFixed(1)}%`);
500
+ const corrScore = Math.max(0, pearson) * 100;
501
+ const overlapScore = overlapRatio * 100;
502
+ const score = corrScore * 0.7 + overlapScore * 0.3;
503
+ notes.push(`target: @${target.handle}, ${target.wallet.slice(0, 8)}...`);
504
+ return {
505
+ detector: "correlation",
506
+ score: Math.round(Math.max(0, Math.min(100, score))),
507
+ samples: posts.length + txs.length,
508
+ notes,
509
+ runtime_ms: 0
510
+ };
511
+ }
512
+ function pearsonCorrelation(x, y) {
513
+ if (x.length !== y.length || x.length === 0) return 0;
514
+ const n = x.length;
515
+ const meanX = x.reduce((a, b) => a + b, 0) / n;
516
+ const meanY = y.reduce((a, b) => a + b, 0) / n;
517
+ let num = 0;
518
+ let denX = 0;
519
+ let denY = 0;
520
+ for (let i = 0; i < n; i++) {
521
+ const dx = x[i] - meanX;
522
+ const dy = y[i] - meanY;
523
+ num += dx * dy;
524
+ denX += dx * dx;
525
+ denY += dy * dy;
526
+ }
527
+ const denom = Math.sqrt(denX * denY);
528
+ return denom === 0 ? 0 : num / denom;
529
+ }
530
+ function minOf(arr) {
531
+ return arr.reduce((a, b) => a < b ? a : b, arr[0] ?? 0);
532
+ }
533
+ function maxOf(arr) {
534
+ return arr.reduce((a, b) => a > b ? a : b, arr[0] ?? 0);
535
+ }
536
+
537
+ // src/core/aggregator.ts
538
+ var DEFAULT_WEIGHTS = {
539
+ cadence: 0.25,
540
+ onchain: 0.25,
541
+ voice: 0.2,
542
+ timing: 0.15,
543
+ correlation: 0.15
544
+ };
545
+ var DEFAULT_THRESHOLDS = {
546
+ autonomous: 80,
547
+ hybrid: 40
548
+ };
549
+ var DISAGREEMENT_DELTA = 40;
550
+ function aggregate(target, detectors, options = {}) {
551
+ const weights = { ...DEFAULT_WEIGHTS, ...options.weights };
552
+ const thresholds = options.thresholds ?? DEFAULT_THRESHOLDS;
553
+ const normalized = {};
554
+ for (const d of detectors) {
555
+ if (d.detector === "timing") {
556
+ normalized.timing = Math.max(0, 100 - d.score * 10);
557
+ } else {
558
+ normalized[d.detector] = d.score;
559
+ }
560
+ }
561
+ const presentKeys = Object.keys(normalized);
562
+ const totalWeight = presentKeys.reduce((s, k) => s + (weights[k] ?? 0), 0);
563
+ let aggregate2 = 0;
564
+ for (const k of presentKeys) {
565
+ const w = (weights[k] ?? 0) / totalWeight;
566
+ aggregate2 += (normalized[k] ?? 0) * w;
567
+ }
568
+ const scoreValues = presentKeys.map((k) => normalized[k] ?? 0);
569
+ const maxScore = Math.max(...scoreValues);
570
+ const minScore = Math.min(...scoreValues);
571
+ const disagreement = maxScore - minScore;
572
+ const hasDisagreement = disagreement >= DISAGREEMENT_DELTA;
573
+ let verdict;
574
+ if (aggregate2 >= thresholds.autonomous && !hasDisagreement) {
575
+ verdict = "AUTONOMOUS";
576
+ } else if (aggregate2 >= thresholds.hybrid || hasDisagreement) {
577
+ verdict = "HYBRID";
578
+ } else {
579
+ verdict = "HUMAN";
580
+ }
581
+ const confidence = computeConfidence(aggregate2, thresholds, hasDisagreement);
582
+ const signals = {
583
+ posting_cadence: Math.round(normalized.cadence ?? 0),
584
+ onchain_cadence: Math.round(normalized.onchain ?? 0),
585
+ voice_consistency: Math.round(normalized.voice ?? 0),
586
+ timing_anomalies: detectors.find((d) => d.detector === "timing")?.score ?? 0,
587
+ correlation: Math.round(normalized.correlation ?? 0)
588
+ };
589
+ return {
590
+ id: `vrd_${generateId()}`,
591
+ target,
592
+ verdict,
593
+ confidence: Number(confidence.toFixed(3)),
594
+ aggregate_score: Number(aggregate2.toFixed(2)),
595
+ signals,
596
+ detectors,
597
+ scanned_at: Math.floor(Date.now() / 1e3),
598
+ version: VERSION
599
+ };
600
+ }
601
+ function computeConfidence(aggregate2, thresholds, hasDisagreement) {
602
+ if (hasDisagreement) {
603
+ return 0.55 + Math.random() * 0.2;
604
+ }
605
+ let distance;
606
+ if (aggregate2 >= thresholds.autonomous) {
607
+ distance = aggregate2 - thresholds.autonomous;
608
+ } else if (aggregate2 >= thresholds.hybrid) {
609
+ distance = Math.min(
610
+ aggregate2 - thresholds.hybrid,
611
+ thresholds.autonomous - aggregate2
612
+ );
613
+ } else {
614
+ distance = thresholds.hybrid - aggregate2;
615
+ }
616
+ return Math.min(0.98, 0.55 + distance / 40 * 0.43);
617
+ }
618
+ function generateId() {
619
+ const bytes = new Uint8Array(6);
620
+ crypto.getRandomValues(bytes);
621
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
622
+ }
623
+
624
+ // src/core/client.ts
625
+ var PulseProtocol = class {
626
+ events;
627
+ helius;
628
+ x;
629
+ config;
630
+ constructor(config) {
631
+ if (!config.heliusApiKey) {
632
+ throw new Error("heliusApiKey is required. Get one at https://helius.xyz");
633
+ }
634
+ if (!config.xApiKey) {
635
+ throw new Error("xApiKey is required.");
636
+ }
637
+ this.config = {
638
+ heliusApiKey: config.heliusApiKey,
639
+ xApiKey: config.xApiKey,
640
+ rpcUrl: config.rpcUrl,
641
+ feedUrl: config.feedUrl,
642
+ signingKey: config.signingKey,
643
+ timeout: config.timeout ?? 3e4,
644
+ maxRetries: config.maxRetries ?? 2
645
+ };
646
+ this.helius = new HeliusSource({
647
+ apiKey: config.heliusApiKey,
648
+ rpcUrl: config.rpcUrl,
649
+ timeout: this.config.timeout,
650
+ maxRetries: this.config.maxRetries
651
+ });
652
+ this.x = new XSource({
653
+ apiKey: config.xApiKey,
654
+ timeout: this.config.timeout,
655
+ maxRetries: this.config.maxRetries
656
+ });
657
+ this.events = new EventEmitter();
658
+ }
659
+ /**
660
+ * Run a full scan on a target and return a verdict.
661
+ *
662
+ * This fetches X posts, on-chain history, cross-correlates them, runs all
663
+ * five detectors, and aggregates the result. A typical scan completes in
664
+ * 2-8 seconds depending on source latency.
665
+ */
666
+ async scan(input, options = {}) {
667
+ this.events.emit("scan:start", input);
668
+ const [posts, txs] = await Promise.all([
669
+ this.x.getRecentPosts(input.handle, { limit: 200 }),
670
+ this.helius.getWalletHistory(input.wallet, { limit: 500 })
671
+ ]);
672
+ if (posts.length === 0 && txs.length === 0) {
673
+ throw new ScanError(
674
+ `No data found for @${input.handle} or wallet ${input.wallet.slice(0, 8)}...`
675
+ );
676
+ }
677
+ const requested = options.only ?? ["cadence", "onchain", "voice", "timing", "correlation"];
678
+ const results = [];
679
+ for (const name of requested) {
680
+ const started = Date.now();
681
+ try {
682
+ let result;
683
+ switch (name) {
684
+ case "cadence":
685
+ result = cadenceDetector(posts);
686
+ break;
687
+ case "onchain":
688
+ result = onchainDetector(txs);
689
+ break;
690
+ case "voice":
691
+ result = voiceDetector(posts);
692
+ break;
693
+ case "timing":
694
+ result = timingDetector(posts, txs);
695
+ break;
696
+ case "correlation":
697
+ result = correlationDetector(posts, txs, input);
698
+ break;
699
+ }
700
+ result.runtime_ms = Date.now() - started;
701
+ results.push(result);
702
+ this.events.emit("detector:done", result);
703
+ } catch (err) {
704
+ throw new ScanError(
705
+ `Detector ${name} failed: ${err.message}`,
706
+ name
707
+ );
708
+ }
709
+ }
710
+ const verdict = aggregate(input, results, options);
711
+ this.events.emit("scan:done", verdict);
712
+ return verdict;
713
+ }
714
+ /**
715
+ * Scan a batch of targets concurrently. Respects the configured rate limits
716
+ * on underlying sources. Use for watchlists.
717
+ */
718
+ async scanMany(inputs, options = {}) {
719
+ const results = [];
720
+ for (const input of inputs) {
721
+ try {
722
+ results.push(await this.scan(input, options));
723
+ } catch (err) {
724
+ this.events.emit("scan:error", { input, error: err });
725
+ }
726
+ }
727
+ return results;
728
+ }
729
+ };
730
+
731
+ // src/core/signing.ts
732
+ import * as ed from "@noble/ed25519";
733
+ import { sha256 } from "@noble/hashes/sha256";
734
+
735
+ // src/index.ts
736
+ var VERSION = "0.9.3";
737
+
738
+ // src/cli/print.ts
739
+ import chalk from "chalk";
740
+ function printVerdict(verdict, format) {
741
+ if (format === "json") {
742
+ console.log(JSON.stringify(verdict, null, 2));
743
+ return;
744
+ }
745
+ if (format === "verdict-only") {
746
+ console.log(verdict.verdict);
747
+ return;
748
+ }
749
+ const color = verdictColor(verdict.verdict);
750
+ const line = "\u2500".repeat(60);
751
+ console.log();
752
+ console.log(chalk.gray(line));
753
+ console.log(
754
+ chalk.bold.magenta("PULSE PROTOCOL ") + chalk.gray("\xB7") + chalk.gray(` v${verdict.version}`)
755
+ );
756
+ console.log(chalk.gray(line));
757
+ console.log();
758
+ console.log(
759
+ chalk.gray("target: ") + chalk.white(`@${verdict.target.handle}`)
760
+ );
761
+ console.log(
762
+ chalk.gray("wallet: ") + chalk.white(verdict.target.wallet.slice(0, 8) + "..." + verdict.target.wallet.slice(-4))
763
+ );
764
+ console.log(
765
+ chalk.gray("scanned: ") + chalk.white(new Date(verdict.scanned_at * 1e3).toISOString())
766
+ );
767
+ console.log();
768
+ console.log(chalk.gray("signals:"));
769
+ console.log(
770
+ ` ${chalk.gray("cadence ")} ${bar(verdict.signals.posting_cadence)} ${chalk.white(verdict.signals.posting_cadence)}`
771
+ );
772
+ console.log(
773
+ ` ${chalk.gray("onchain ")} ${bar(verdict.signals.onchain_cadence)} ${chalk.white(verdict.signals.onchain_cadence)}`
774
+ );
775
+ console.log(
776
+ ` ${chalk.gray("voice ")} ${bar(verdict.signals.voice_consistency)} ${chalk.white(verdict.signals.voice_consistency)}`
777
+ );
778
+ console.log(
779
+ ` ${chalk.gray("timing ")} ${chalk.white(verdict.signals.timing_anomalies)} anomalies`
780
+ );
781
+ console.log(
782
+ ` ${chalk.gray("correlation ")} ${bar(verdict.signals.correlation)} ${chalk.white(verdict.signals.correlation)}`
783
+ );
784
+ console.log();
785
+ console.log(chalk.gray(line));
786
+ console.log(
787
+ "verdict: " + color(chalk.bold(verdict.verdict)) + chalk.gray(
788
+ ` (${(verdict.confidence * 100).toFixed(0)}% confidence, aggregate ${verdict.aggregate_score}/100)`
789
+ )
790
+ );
791
+ console.log(chalk.gray(line));
792
+ console.log();
793
+ }
794
+ function verdictColor(v) {
795
+ if (v === "AUTONOMOUS") return chalk.magenta;
796
+ if (v === "HYBRID") return chalk.magentaBright;
797
+ return chalk.gray;
798
+ }
799
+ function bar(value) {
800
+ const width = 20;
801
+ const filled = Math.round(value / 100 * width);
802
+ const empty = width - filled;
803
+ return chalk.magenta("\u2588".repeat(filled)) + chalk.gray("\u2591".repeat(empty));
804
+ }
805
+
806
+ // src/daemon/index.ts
807
+ import * as fs from "fs";
808
+ import YAML from "yaml";
809
+ import chalk2 from "chalk";
810
+ async function runDaemon(opts) {
811
+ const intervalMs = parseDuration(opts.interval);
812
+ const pulse = new PulseProtocol(opts.config);
813
+ const previous = /* @__PURE__ */ new Map();
814
+ console.log(
815
+ chalk2.magenta(`pulse daemon started \xB7 interval ${opts.interval}`)
816
+ );
817
+ const tick = async () => {
818
+ const raw = fs.readFileSync(opts.watchlistPath, "utf8");
819
+ const parsed = YAML.parse(raw);
820
+ const targets = parsed.targets ?? [];
821
+ for (const target of targets) {
822
+ try {
823
+ const verdict = await pulse.scan(target);
824
+ const key = `${target.handle}:${target.wallet}`;
825
+ const prev = previous.get(key);
826
+ if (!prev || prev.verdict !== verdict.verdict) {
827
+ console.log(
828
+ chalk2.magenta(
829
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] @${target.handle}: ${prev?.verdict ?? "NEW"} \u2192 ${verdict.verdict}`
830
+ )
831
+ );
832
+ if (opts.webhook) {
833
+ await emitWebhook(opts.webhook, { previous: prev, current: verdict });
834
+ }
835
+ } else {
836
+ console.log(
837
+ chalk2.gray(
838
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] @${target.handle}: ${verdict.verdict} (unchanged)`
839
+ )
840
+ );
841
+ }
842
+ previous.set(key, verdict);
843
+ } catch (err) {
844
+ console.error(
845
+ chalk2.red(`scan failed for @${target.handle}: ${err.message}`)
846
+ );
847
+ }
848
+ }
849
+ };
850
+ await tick();
851
+ setInterval(tick, intervalMs);
852
+ }
853
+ async function emitWebhook(url, payload) {
854
+ try {
855
+ await fetch(url, {
856
+ method: "POST",
857
+ headers: { "Content-Type": "application/json" },
858
+ body: JSON.stringify(payload)
859
+ });
860
+ } catch (err) {
861
+ console.error(chalk2.red(`webhook failed: ${err.message}`));
862
+ }
863
+ }
864
+ function parseDuration(s) {
865
+ const match = s.match(/^(\d+)(s|m|h|d)$/);
866
+ if (!match) throw new Error(`invalid duration: ${s}`);
867
+ const n = Number(match[1]);
868
+ const unit = match[2];
869
+ switch (unit) {
870
+ case "s":
871
+ return n * 1e3;
872
+ case "m":
873
+ return n * 6e4;
874
+ case "h":
875
+ return n * 36e5;
876
+ case "d":
877
+ return n * 864e5;
878
+ default:
879
+ throw new Error(`invalid unit: ${unit}`);
880
+ }
881
+ }
882
+
883
+ // src/cli/index.ts
884
+ var program = new Command();
885
+ program.name("pulse").description("Pulse Protocol \u2014 fake agents have no pulse").version(VERSION);
886
+ program.command("scan").description("Scan a target and return a verdict").requiredOption("--handle <handle>", "X / Twitter handle (no @)").requiredOption("--wallet <wallet>", "Solana wallet address").option("--out <format>", "Output format: pretty | json | verdict-only", "pretty").option("--no-post", "Do not publish verdict to the public feed").action(async (opts) => {
887
+ const config = loadConfig();
888
+ const pulse = new PulseProtocol(config);
889
+ const spinner = ora({
890
+ text: chalk3.magenta(`scanning @${opts.handle}...`),
891
+ color: "magenta"
892
+ }).start();
893
+ try {
894
+ const verdict = await pulse.scan(
895
+ { handle: opts.handle, wallet: opts.wallet },
896
+ { noPost: !opts.post }
897
+ );
898
+ spinner.stop();
899
+ printVerdict(verdict, opts.out);
900
+ } catch (err) {
901
+ spinner.fail(chalk3.red(err.message));
902
+ process.exit(1);
903
+ }
904
+ });
905
+ program.command("watch").description("Start a watchlist daemon that re-scans targets on an interval").option("--config <path>", "Path to watchlist YAML").option("--interval <duration>", "Scan interval", "6h").option("--webhook <url>", "POST verdict changes to this URL").action(async (opts) => {
906
+ const config = loadConfig();
907
+ const watchlistPath = opts.config ?? path.join(os.homedir(), ".config/pulse/watchlist.yaml");
908
+ if (!fs2.existsSync(watchlistPath)) {
909
+ console.error(chalk3.red(`watchlist not found: ${watchlistPath}`));
910
+ console.log(chalk3.gray(`create one with:`));
911
+ console.log(chalk3.gray(` targets:
912
+ - handle: foo
913
+ wallet: abc...`));
914
+ process.exit(1);
915
+ }
916
+ await runDaemon({
917
+ config,
918
+ watchlistPath,
919
+ interval: opts.interval,
920
+ webhook: opts.webhook
921
+ });
922
+ });
923
+ program.command("init").description("Initialize ~/.config/pulse/.env with empty keys").action(() => {
924
+ const dir = path.join(os.homedir(), ".config/pulse");
925
+ fs2.mkdirSync(dir, { recursive: true });
926
+ const envPath = path.join(dir, ".env");
927
+ if (fs2.existsSync(envPath)) {
928
+ console.log(chalk3.yellow(`already exists: ${envPath}`));
929
+ return;
930
+ }
931
+ fs2.writeFileSync(
932
+ envPath,
933
+ `# Pulse Protocol configuration
934
+ HELIUS_API_KEY=
935
+ X_API_KEY=
936
+ `
937
+ );
938
+ console.log(chalk3.green(`created ${envPath}`));
939
+ console.log(chalk3.gray(`fill in your API keys, then run: pulse scan --help`));
940
+ });
941
+ program.command("feed").description("Stream the public verdict feed").action(() => {
942
+ console.log(chalk3.magenta("streaming public feed..."));
943
+ console.log(chalk3.gray("(connect to wss://feed.pulseprotocol.tech/ws)"));
944
+ });
945
+ program.command("verify <verdictId>").description("Verify the signature on a public verdict").action((verdictId) => {
946
+ console.log(chalk3.magenta(`verifying ${verdictId}...`));
947
+ console.log(chalk3.gray("(fetches from https://pulseprotocol.tech/v/)"));
948
+ });
949
+ function loadConfig() {
950
+ const envPath = path.join(os.homedir(), ".config/pulse/.env");
951
+ if (fs2.existsSync(envPath)) {
952
+ const env = fs2.readFileSync(envPath, "utf8");
953
+ for (const line of env.split("\n")) {
954
+ const match = line.match(/^([A-Z_]+)=(.*)$/);
955
+ if (match && match[1] && !process.env[match[1]]) {
956
+ process.env[match[1]] = match[2];
957
+ }
958
+ }
959
+ }
960
+ const heliusApiKey = process.env.HELIUS_API_KEY;
961
+ const xApiKey = process.env.X_API_KEY;
962
+ if (!heliusApiKey || !xApiKey) {
963
+ console.error(chalk3.red("missing HELIUS_API_KEY or X_API_KEY"));
964
+ console.error(chalk3.gray("run: pulse init"));
965
+ process.exit(1);
966
+ }
967
+ return { heliusApiKey, xApiKey };
968
+ }
969
+ program.parse(process.argv);
970
+ //# sourceMappingURL=cli.js.map