vibedate 0.2.1 → 0.3.1

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.
@@ -432,6 +432,115 @@ function grantLiveConsent(dir = defaultStateDir()) {
432
432
  function canShareLive(dir = defaultStateDir()) {
433
433
  return createLedger(dir).allows(LIVE_CONSENT_SCOPE);
434
434
  }
435
+ var HANDLE_FILE = "handle.json";
436
+ var DEFAULT_HANDLE = "@you";
437
+ var MAX_HANDLE_LEN = 32;
438
+ function handleFilePath(dir) {
439
+ return path2.join(dir, HANDLE_FILE);
440
+ }
441
+ function stripLeadingAt(handle) {
442
+ return handle.replace(/^@+/, "");
443
+ }
444
+ function sameHandle(a, b) {
445
+ return stripLeadingAt(a) === stripLeadingAt(b);
446
+ }
447
+ function normalizeHandle(input) {
448
+ if (typeof input !== "string") return null;
449
+ const trimmed = input.trim();
450
+ if (trimmed.length === 0) return null;
451
+ const body = stripLeadingAt(trimmed);
452
+ if (body.length === 0) return null;
453
+ if (/\s/.test(body) || /[\x00-\x1f\x7f]/.test(body)) return null;
454
+ const canonical = "@" + body;
455
+ if (canonical.length > MAX_HANDLE_LEN) return null;
456
+ return canonical;
457
+ }
458
+ function loadHandle(dir = defaultStateDir()) {
459
+ try {
460
+ const raw = readFileSync(handleFilePath(dir), "utf8");
461
+ const data = JSON.parse(raw);
462
+ if (typeof data["handle"] !== "string") return DEFAULT_HANDLE;
463
+ return normalizeHandle(data["handle"]) ?? DEFAULT_HANDLE;
464
+ } catch {
465
+ return DEFAULT_HANDLE;
466
+ }
467
+ }
468
+ function saveHandle(handle, dir = defaultStateDir()) {
469
+ const canonical = normalizeHandle(handle);
470
+ if (canonical === null) {
471
+ throw new Error(
472
+ `invalid handle: use 1-${MAX_HANDLE_LEN} chars (optional leading '@'), no whitespace`
473
+ );
474
+ }
475
+ mkdirSync(dir, { recursive: true });
476
+ writeFileSync2(handleFilePath(dir), JSON.stringify({ handle: canonical }, null, 2) + "\n", "utf8");
477
+ const existing = loadProfile(dir);
478
+ if (existing !== null) {
479
+ writeFileSync2(
480
+ profilePath(dir),
481
+ JSON.stringify({ ...existing, handle: canonical }, null, 2) + "\n",
482
+ "utf8"
483
+ );
484
+ }
485
+ return canonical;
486
+ }
487
+ function resolveHandle(dir = defaultStateDir()) {
488
+ const env = process.env["VIBEDATING_HANDLE"];
489
+ if (env !== void 0 && env.trim() !== "") {
490
+ const canonical = normalizeHandle(env);
491
+ if (canonical !== null) return canonical;
492
+ }
493
+ return loadHandle(dir);
494
+ }
495
+ var BLOCKLIST_FILE = "blocklist.json";
496
+ function blocklistPath(dir) {
497
+ return path2.join(dir, BLOCKLIST_FILE);
498
+ }
499
+ function persistBlocklist(blocked, dir) {
500
+ mkdirSync(dir, { recursive: true });
501
+ writeFileSync2(blocklistPath(dir), JSON.stringify({ blocked }, null, 2) + "\n", "utf8");
502
+ }
503
+ function loadBlocklist(dir = defaultStateDir()) {
504
+ try {
505
+ const raw = readFileSync(blocklistPath(dir), "utf8");
506
+ const data = JSON.parse(raw);
507
+ if (!Array.isArray(data["blocked"])) return [];
508
+ return data["blocked"].filter((h) => typeof h === "string");
509
+ } catch {
510
+ return [];
511
+ }
512
+ }
513
+ function isBlocked(handle, dir = defaultStateDir()) {
514
+ const want = stripLeadingAt(handle);
515
+ if (want === "") return false;
516
+ return loadBlocklist(dir).some((entry) => stripLeadingAt(entry) === want);
517
+ }
518
+ function addBlock(handle, dir = defaultStateDir()) {
519
+ const canonical = normalizeHandle(handle);
520
+ if (canonical === null) {
521
+ throw new Error(
522
+ `invalid handle: use 1-${MAX_HANDLE_LEN} chars (optional leading '@'), no whitespace`
523
+ );
524
+ }
525
+ const list = loadBlocklist(dir);
526
+ if (list.some((e) => sameHandle(e, canonical))) return { blocked: list, changed: false };
527
+ const blocked = [...list, canonical];
528
+ persistBlocklist(blocked, dir);
529
+ return { blocked, changed: true };
530
+ }
531
+ function removeBlock(handle, dir = defaultStateDir()) {
532
+ const canonical = normalizeHandle(handle);
533
+ if (canonical === null) {
534
+ throw new Error(
535
+ `invalid handle: use 1-${MAX_HANDLE_LEN} chars (optional leading '@'), no whitespace`
536
+ );
537
+ }
538
+ const list = loadBlocklist(dir);
539
+ const blocked = list.filter((e) => !sameHandle(e, canonical));
540
+ if (blocked.length === list.length) return { blocked: list, changed: false };
541
+ persistBlocklist(blocked, dir);
542
+ return { blocked, changed: true };
543
+ }
435
544
 
436
545
  // src/p2p.ts
437
546
  var TOPIC_PREFIX = "vibedate:";
@@ -439,7 +548,7 @@ function leagueTopic(leagueName) {
439
548
  return createHash("sha256").update(`${TOPIC_PREFIX}${leagueName}`, "utf8").digest();
440
549
  }
441
550
  var LIVE_NOTICE = "live discovery: sharing only your handle + league + harness (never raw usage) with same-league peers on the public DHT";
442
- var MAX_HANDLE_LEN = 64;
551
+ var MAX_HANDLE_LEN2 = 64;
443
552
  var MAX_LEAGUE_LEN = 32;
444
553
  var MAX_HARNESS_LEN = 64;
445
554
  var MAX_HANDSHAKE_LEN = 4096;
@@ -464,7 +573,7 @@ function parseHandshake(raw) {
464
573
  const rec = data;
465
574
  const handle = rec["handle"];
466
575
  const league2 = rec["league"];
467
- if (typeof handle !== "string" || handle.length === 0 || handle.length > MAX_HANDLE_LEN) {
576
+ if (typeof handle !== "string" || handle.length === 0 || handle.length > MAX_HANDLE_LEN2) {
468
577
  return null;
469
578
  }
470
579
  if (typeof league2 !== "string" || league2.length === 0 || league2.length > MAX_LEAGUE_LEN) {
@@ -511,7 +620,9 @@ function recordPeer(hello, dir = defaultStateDir(), now = /* @__PURE__ */ new Da
511
620
  }
512
621
  async function startDiscovery(opts) {
513
622
  const { hello, stateDir = defaultStateDir(), onPeer, onLink, notify = vibeCoreNotify } = opts;
514
- const topic = opts.topic ?? leagueTopic(hello.league);
623
+ const isBlocked2 = opts.isBlocked;
624
+ const topics = opts.topics ? [...opts.topics] : opts.topic !== void 0 ? [opts.topic] : [leagueTopic(hello.league)];
625
+ const acceptLeague = opts.acceptLeague ?? ((l) => l === hello.league);
515
626
  const { default: Hyperswarm } = await import("hyperswarm");
516
627
  const swarm = new Hyperswarm(opts.bootstrap === void 0 ? {} : { bootstrap: opts.bootstrap });
517
628
  await swarm.dht.fullyBootstrapped();
@@ -539,7 +650,8 @@ async function startDiscovery(opts) {
539
650
  league: frame.league,
540
651
  harness: frame.harness
541
652
  };
542
- if (peer.league !== hello.league) continue;
653
+ if (!acceptLeague(peer.league)) continue;
654
+ if (isBlocked2 !== void 0 && isBlocked2(peer.handle)) continue;
543
655
  peers.set(remoteKey, peer);
544
656
  const { isNew } = recordPeer(peer, stateDir);
545
657
  if (isNew) {
@@ -570,17 +682,22 @@ async function startDiscovery(opts) {
570
682
  socket.on("error", () => {
571
683
  });
572
684
  });
573
- const discovery = swarm.join(topic, { server: true, client: true });
574
- const ready = discovery.flushed().catch(() => void 0);
685
+ const discoveries = topics.map((t) => swarm.join(t, { server: true, client: true }));
686
+ const ready = Promise.all(
687
+ discoveries.map((d) => d.flushed().catch(() => void 0))
688
+ );
575
689
  await ready;
576
690
  const refresher = setInterval(() => {
577
- void discovery.refresh({ server: true, client: true }).catch(() => {
691
+ for (const d of discoveries) void d.refresh({ server: true, client: true }).catch(() => {
578
692
  });
579
693
  }, REFRESH_INTERVAL_MS);
580
694
  refresher.unref();
581
695
  let closed = false;
582
696
  return {
583
- topic,
697
+ topic: topics[0],
698
+ // primary (first) — kept for back-compat / display
699
+ topics,
700
+ // every joined topic (primary first)
584
701
  hello,
585
702
  peers,
586
703
  ready,
@@ -588,9 +705,11 @@ async function startDiscovery(opts) {
588
705
  if (closed) return;
589
706
  closed = true;
590
707
  clearInterval(refresher);
591
- try {
592
- await swarm.leave(topic);
593
- } catch {
708
+ for (const t of topics) {
709
+ try {
710
+ await swarm.leave(t);
711
+ } catch {
712
+ }
594
713
  }
595
714
  await swarm.destroy();
596
715
  }
@@ -619,6 +738,22 @@ function leagueIndex(name) {
619
738
  if (name === BELOW_LEAGUE) return -1;
620
739
  return LEAGUES.findIndex((l) => l.name === name);
621
740
  }
741
+ function allLeagueNames() {
742
+ return [BELOW_LEAGUE, ...LEAGUES.map((l) => l.name)];
743
+ }
744
+ function leaguesWithin(name, width) {
745
+ const w = Math.max(0, Math.trunc(width));
746
+ if (name === BELOW_LEAGUE) {
747
+ return [BELOW_LEAGUE, ...LEAGUES.slice(0, w).map((l) => l.name)];
748
+ }
749
+ const center = leagueIndex(name);
750
+ if (center < 0) return [];
751
+ const lo = Math.max(0, center - w);
752
+ const hi = Math.min(LEAGUES.length - 1, center + w);
753
+ const out = [];
754
+ for (let i = lo; i <= hi; i++) out.push(LEAGUES[i].name);
755
+ return out;
756
+ }
622
757
  var TOKENS_ENV = "VIBEDATING_TOKENS";
623
758
  var DEMO_TOTAL_TOKENS = 234e5;
624
759
  var MS_PER_DAY = 864e5;
@@ -721,6 +856,14 @@ export {
721
856
  loadProfile,
722
857
  grantLiveConsent,
723
858
  canShareLive,
859
+ sameHandle,
860
+ normalizeHandle,
861
+ saveHandle,
862
+ resolveHandle,
863
+ loadBlocklist,
864
+ isBlocked,
865
+ addBlock,
866
+ removeBlock,
724
867
  TOPIC_PREFIX,
725
868
  leagueTopic,
726
869
  LIVE_NOTICE,
@@ -733,6 +876,8 @@ export {
733
876
  BELOW_LEAGUE,
734
877
  league,
735
878
  leagueIndex,
879
+ allLeagueNames,
880
+ leaguesWithin,
736
881
  TOKENS_ENV,
737
882
  DEMO_TOTAL_TOKENS,
738
883
  readUsage,
@@ -2,7 +2,7 @@ import {
2
2
  CANDIDATES,
3
3
  loadProfile,
4
4
  matches
5
- } from "./chunk-6NWF2VD7.js";
5
+ } from "./chunk-3VT4EOBX.js";
6
6
 
7
7
  // src/mcp.ts
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
package/dist/cli.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /** Recognized top-level commands, plus the synthetic help/version. */
3
- type Command = 'connect' | 'matches' | 'discover' | 'open' | 'live' | 'mcp' | 'help' | 'version' | null;
3
+ type Command = 'connect' | 'matches' | 'discover' | 'open' | 'live' | 'find' | 'handle' | 'block' | 'unblock' | 'blocklist' | 'mcp' | 'help' | 'version' | null;
4
4
  interface ParsedArgs {
5
5
  readonly command: Command;
6
6
  /** Port for `open --port`; undefined means "let the OS pick". */
@@ -9,6 +9,12 @@ interface ParsedArgs {
9
9
  readonly live: boolean;
10
10
  /** `live --dating`: pick-a-handle mode vs omegle auto-pair. Default false. */
11
11
  readonly dating: boolean;
12
+ /** `discover --any` / `live --any`: match every league (not just ±1). Default false. */
13
+ readonly any: boolean;
14
+ /** Positional argument for `handle`/`find` (e.g. `@name`). */
15
+ readonly arg: string | undefined;
16
+ /** `live --to <@handle>`: targeted match — auto-open that specific peer. */
17
+ readonly to: string | undefined;
12
18
  }
13
19
  /**
14
20
  * Parse argv (the slice AFTER the program name) into a command + options.
package/dist/cli.js CHANGED
@@ -1,23 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runMcp
4
- } from "./chunk-5ZJIPMU6.js";
4
+ } from "./chunk-OVO2CQ7X.js";
5
5
  import {
6
6
  CANDIDATES,
7
7
  LIVE_NOTICE,
8
8
  TOPIC_PREFIX,
9
+ addBlock,
10
+ allLeagueNames,
9
11
  canShareLive,
10
12
  connectProfile,
11
13
  grantLiveConsent,
14
+ isBlocked,
12
15
  league,
13
16
  leagueIndex,
17
+ leagueTopic,
18
+ leaguesWithin,
19
+ loadBlocklist,
14
20
  loadPeers,
15
21
  loadProfile,
16
22
  matches,
23
+ normalizeHandle,
17
24
  parseFrame,
18
25
  readUsage,
26
+ removeBlock,
27
+ resolveHandle,
28
+ sameHandle,
29
+ saveHandle,
19
30
  startDiscovery
20
- } from "./chunk-6NWF2VD7.js";
31
+ } from "./chunk-3VT4EOBX.js";
21
32
 
22
33
  // src/cli.ts
23
34
  import readline from "readline";
@@ -1387,26 +1398,54 @@ async function handle(req, res, opts) {
1387
1398
  }
1388
1399
 
1389
1400
  // src/cli.ts
1390
- var VERSION = "0.2.1";
1401
+ var VERSION = "0.3.1";
1391
1402
  function parsePort(raw) {
1392
1403
  const n = Number(raw);
1393
1404
  if (!Number.isInteger(n) || n < 1 || n > 65535) return void 0;
1394
1405
  return n;
1395
1406
  }
1396
1407
  function parseArgs(argv) {
1397
- let out = { command: null, port: void 0, live: false, dating: false };
1408
+ let out = {
1409
+ command: null,
1410
+ port: void 0,
1411
+ live: false,
1412
+ dating: false,
1413
+ any: false,
1414
+ arg: void 0,
1415
+ to: void 0
1416
+ };
1398
1417
  for (let i = 0; i < argv.length; i++) {
1399
1418
  const a = argv[i];
1400
1419
  if (a === "--version" || a === "-v") {
1401
- return { command: "version", port: void 0, live: false, dating: false };
1420
+ return {
1421
+ command: "version",
1422
+ port: void 0,
1423
+ live: false,
1424
+ dating: false,
1425
+ any: false,
1426
+ arg: void 0,
1427
+ to: void 0
1428
+ };
1402
1429
  }
1403
1430
  if (a === "--help" || a === "-h") {
1404
- return { command: "help", port: void 0, live: false, dating: false };
1431
+ return {
1432
+ command: "help",
1433
+ port: void 0,
1434
+ live: false,
1435
+ dating: false,
1436
+ any: false,
1437
+ arg: void 0,
1438
+ to: void 0
1439
+ };
1405
1440
  }
1406
1441
  if (a === "--live") {
1407
1442
  out = { ...out, live: true };
1408
1443
  continue;
1409
1444
  }
1445
+ if (a === "--any") {
1446
+ out = { ...out, any: true };
1447
+ continue;
1448
+ }
1410
1449
  if (a === "--dating") {
1411
1450
  out = { ...out, dating: true };
1412
1451
  continue;
@@ -1425,10 +1464,24 @@ function parseArgs(argv) {
1425
1464
  if (p !== void 0) out = { ...out, port: p };
1426
1465
  continue;
1427
1466
  }
1467
+ if (a === "--to") {
1468
+ const next = argv[i + 1];
1469
+ if (next !== void 0) {
1470
+ out = { ...out, to: next };
1471
+ i++;
1472
+ }
1473
+ continue;
1474
+ }
1475
+ if (a.startsWith("--to=")) {
1476
+ out = { ...out, to: a.slice("--to=".length) };
1477
+ continue;
1478
+ }
1428
1479
  if (a.startsWith("-")) continue;
1429
- const known = a === "connect" || a === "matches" || a === "discover" || a === "open" || a === "live" || a === "mcp" || a === "help" ? a : null;
1480
+ const known = a === "connect" || a === "matches" || a === "discover" || a === "open" || a === "live" || a === "find" || a === "handle" || a === "block" || a === "unblock" || a === "blocklist" || a === "mcp" || a === "help" ? a : null;
1430
1481
  if (known !== null && out.command === null) {
1431
1482
  out = { ...out, command: known };
1483
+ } else if (out.arg === void 0) {
1484
+ out = { ...out, arg: a };
1432
1485
  }
1433
1486
  }
1434
1487
  return out;
@@ -1436,9 +1489,30 @@ function parseArgs(argv) {
1436
1489
  function leagueLabel(name) {
1437
1490
  return name === "below-1M" ? "below 1M (not yet in a league)" : `${name} League`;
1438
1491
  }
1492
+ function discoveryScope(myLeague, any) {
1493
+ const names = any ? allLeagueNames() : leaguesWithin(myLeague, 1);
1494
+ const ordered = [myLeague, ...names.filter((n) => n !== myLeague)];
1495
+ if (any) {
1496
+ return { topics: ordered.map(leagueTopic), acceptLeague: () => true };
1497
+ }
1498
+ const accepted = new Set(names);
1499
+ return {
1500
+ topics: ordered.map(leagueTopic),
1501
+ acceptLeague: (peerLeague) => accepted.has(peerLeague)
1502
+ };
1503
+ }
1504
+ function blockedChecker() {
1505
+ return (handle2) => isBlocked(handle2);
1506
+ }
1507
+ function peerDirection(myLeague, peerLeague, sameBullet = "+") {
1508
+ const d = leagueIndex(peerLeague) - leagueIndex(myLeague);
1509
+ if (d > 0) return { bullet: "\u2191", qual: " \xB7 higher league" };
1510
+ if (d < 0) return { bullet: "\u2193", qual: " \xB7 lower league" };
1511
+ return { bullet: sameBullet, qual: "" };
1512
+ }
1439
1513
  async function cmdConnect() {
1440
1514
  const harness = process2.env["VIBEDATING_HARNESS"] ?? "claude-code";
1441
- const handle2 = process2.env["VIBEDATING_HANDLE"] ?? "@you";
1515
+ const handle2 = resolveHandle();
1442
1516
  const snapshot = await readUsage(harness);
1443
1517
  const profile = connectProfile(snapshot, handle2);
1444
1518
  const lg = league(snapshot.totalTokens);
@@ -1455,6 +1529,28 @@ async function cmdConnect() {
1455
1529
  process2.stdout.write(" \u2022 raw usage stays local \xB7 only league shared\n\n");
1456
1530
  return 0;
1457
1531
  }
1532
+ async function cmdHandle(arg) {
1533
+ if (arg === void 0 || arg.trim() === "") {
1534
+ const handle2 = resolveHandle();
1535
+ process2.stdout.write(`${handle2}
1536
+ `);
1537
+ const env = process2.env["VIBEDATING_HANDLE"];
1538
+ if (env !== void 0 && env.trim() !== "" && normalizeHandle(env) !== null) {
1539
+ process2.stdout.write(" (env VIBEDATING_HANDLE overrides the persisted handle for this run)\n");
1540
+ }
1541
+ return 0;
1542
+ }
1543
+ try {
1544
+ const canonical = saveHandle(arg);
1545
+ process2.stdout.write(` handle set \u2192 ${canonical} (saved to ~/.vibedating/handle.json)
1546
+ `);
1547
+ return 0;
1548
+ } catch (err) {
1549
+ process2.stderr.write(`${err instanceof Error ? err.message : String(err)}
1550
+ `);
1551
+ return 1;
1552
+ }
1553
+ }
1458
1554
  async function cmdMatches(live) {
1459
1555
  const profile = loadProfile();
1460
1556
  if (!profile) {
@@ -1508,7 +1604,7 @@ async function cmdMatches(live) {
1508
1604
  process2.stdout.write("\n");
1509
1605
  return 0;
1510
1606
  }
1511
- async function cmdDiscover(live) {
1607
+ async function cmdDiscover(live, any) {
1512
1608
  const profile = loadProfile();
1513
1609
  if (!profile) {
1514
1610
  process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
@@ -1522,27 +1618,39 @@ async function cmdDiscover(live) {
1522
1618
  return 1;
1523
1619
  }
1524
1620
  const hello = {
1525
- handle: profile.handle,
1621
+ handle: resolveHandle(),
1526
1622
  league: profile.league,
1527
1623
  harness: profile.harness
1528
1624
  };
1529
1625
  process2.stdout.write("\n");
1530
1626
  process2.stdout.write(` ${LIVE_NOTICE}
1531
1627
  `);
1628
+ const { topics, acceptLeague } = discoveryScope(profile.league, any);
1532
1629
  const session = await startDiscovery({
1533
1630
  hello,
1631
+ topics,
1632
+ acceptLeague,
1633
+ isBlocked: blockedChecker(),
1534
1634
  onPeer: (peer, isNew) => {
1635
+ const { bullet, qual } = peerDirection(profile.league, peer.league);
1535
1636
  process2.stdout.write(
1536
- ` + ${peer.handle} (${peer.league} \xB7 ${peer.harness})${isNew ? " \u2190 new match" : ""}
1637
+ ` ${bullet} ${peer.handle} (${peer.league}${qual} \xB7 ${peer.harness})${isNew ? " \u2190 new match" : ""}
1537
1638
  `
1538
1639
  );
1539
1640
  }
1540
1641
  });
1541
1642
  process2.stdout.write(
1542
- ` topic: ${TOPIC_PREFIX}${profile.league} \u2192 ${session.topic.toString("hex").slice(0, 12)}\u2026
1643
+ ` topic: ${TOPIC_PREFIX}${profile.league} \u2192 ${session.topic.toString("hex").slice(0, 12)}\u2026${topics.length > 1 ? ` (+${topics.length - 1} more)` : ""}
1543
1644
  `
1544
1645
  );
1545
- process2.stdout.write(" listening for same-league peers\u2026 (Ctrl+C to stop)\n\n");
1646
+ if (!any && session.peers.size === 0) {
1647
+ process2.stdout.write(
1648
+ " no one in your league yet \u2014 also listening to adjacent leagues (use --any to match anyone)\n"
1649
+ );
1650
+ }
1651
+ process2.stdout.write(
1652
+ any ? " listening for ANY league peers\u2026 (Ctrl+C to stop)\n\n" : " listening for same + adjacent-league peers\u2026 (Ctrl+C to stop)\n\n"
1653
+ );
1546
1654
  await new Promise((resolve) => {
1547
1655
  process2.once("SIGINT", () => resolve());
1548
1656
  process2.once("SIGTERM", () => resolve());
@@ -1570,11 +1678,11 @@ async function cmdOpen(port) {
1570
1678
  ${LIVE_NOTICE}
1571
1679
  `);
1572
1680
  const hello = {
1573
- handle: profile.handle,
1681
+ handle: resolveHandle(),
1574
1682
  league: profile.league,
1575
1683
  harness: profile.harness
1576
1684
  };
1577
- void startDiscovery({ hello, onLink: (link) => live.addLink(link) }).then((s) => {
1685
+ void startDiscovery({ hello, isBlocked: blockedChecker(), onLink: (link) => live.addLink(link) }).then((s) => {
1578
1686
  session = s;
1579
1687
  }).catch(() => {
1580
1688
  });
@@ -1598,19 +1706,25 @@ async function cmdOpen(port) {
1598
1706
  await new Promise((resolve) => started.server.close(() => resolve()));
1599
1707
  return 0;
1600
1708
  }
1601
- async function cmdLive(dating) {
1709
+ async function cmdLive(dating, any, to) {
1602
1710
  const profile = loadProfile();
1603
1711
  if (!profile) {
1604
1712
  process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
1605
1713
  return 1;
1606
1714
  }
1715
+ const target = to !== void 0 ? normalizeHandle(to) : null;
1716
+ if (to !== void 0 && target === null) {
1717
+ process2.stderr.write(`invalid target handle: ${to}
1718
+ `);
1719
+ return 1;
1720
+ }
1607
1721
  if (!canShareLive()) grantLiveConsent();
1608
1722
  if (!canShareLive()) {
1609
1723
  process2.stderr.write("Could not enable live discovery. Try `vibedating discover --live`.\n");
1610
1724
  return 1;
1611
1725
  }
1612
1726
  const hello = {
1613
- handle: profile.handle,
1727
+ handle: resolveHandle(),
1614
1728
  league: profile.league,
1615
1729
  harness: profile.harness
1616
1730
  };
@@ -1618,7 +1732,8 @@ async function cmdLive(dating) {
1618
1732
  process2.stdout.write(` ${LIVE_NOTICE}
1619
1733
  `);
1620
1734
  process2.stdout.write(
1621
- dating ? " dating mode \u2014 /open <handle> to pick a peer\n" : " omegle mode \u2014 /next to roll a new peer\n"
1735
+ target !== null ? ` targeted \u2014 auto-opening ${target} when they connect (/quit to stop)
1736
+ ` : dating ? " dating mode \u2014 /open <handle> to pick a peer\n" : " omegle mode \u2014 /next to roll a new peer\n"
1622
1737
  );
1623
1738
  const pairing = createPairing();
1624
1739
  pairing.onMatch((link) => {
@@ -1626,8 +1741,9 @@ async function cmdLive(dating) {
1626
1741
  process2.stdout.write(" \xB7 idle \u2014 no peer right now\n");
1627
1742
  return;
1628
1743
  }
1744
+ const { qual } = peerDirection(profile.league, link.hello.league);
1629
1745
  process2.stdout.write(
1630
- ` \xB7 matched ${link.hello.handle} (${link.hello.league} \xB7 ${link.hello.harness})
1746
+ ` \xB7 matched ${link.hello.handle} (${link.hello.league}${qual} \xB7 ${link.hello.harness})
1631
1747
  `
1632
1748
  );
1633
1749
  link.onMessage((m) => {
@@ -1635,12 +1751,31 @@ async function cmdLive(dating) {
1635
1751
  `);
1636
1752
  });
1637
1753
  });
1754
+ const { topics, acceptLeague } = discoveryScope(profile.league, any);
1638
1755
  const session = await startDiscovery({
1639
1756
  hello,
1640
- onLink: (link) => pairing.add(link)
1757
+ topics,
1758
+ acceptLeague,
1759
+ isBlocked: blockedChecker(),
1760
+ onLink: (link) => {
1761
+ if (target !== null && !sameHandle(link.hello.handle, target)) {
1762
+ const { qual } = peerDirection(profile.league, link.hello.league);
1763
+ process2.stdout.write(
1764
+ ` + ${link.hello.handle} (${link.hello.league}${qual}) \u2014 not your target
1765
+ `
1766
+ );
1767
+ link.close();
1768
+ return;
1769
+ }
1770
+ if (target !== null) {
1771
+ process2.stdout.write(` \u2605 found ${link.hello.handle} \u2014 auto-opening
1772
+ `);
1773
+ }
1774
+ pairing.add(link);
1775
+ }
1641
1776
  });
1642
1777
  process2.stdout.write(
1643
- ` topic: ${TOPIC_PREFIX}${profile.league} \u2192 ${session.topic.toString("hex").slice(0, 12)}\u2026
1778
+ ` topic: ${TOPIC_PREFIX}${profile.league} \u2192 ${session.topic.toString("hex").slice(0, 12)}\u2026${topics.length > 1 ? ` (+${topics.length - 1} more)` : ""}
1644
1779
  `
1645
1780
  );
1646
1781
  process2.stdout.write(" type to chat \xB7 /next \xB7 /open <handle> \xB7 /quit\n");
@@ -1683,13 +1818,144 @@ async function cmdLive(dating) {
1683
1818
  process2.stdout.write("\n");
1684
1819
  return 0;
1685
1820
  }
1821
+ async function cmdFind(targetArg, any) {
1822
+ if (targetArg === void 0 || targetArg.trim() === "") {
1823
+ process2.stderr.write("usage: vibedating find <@handle> [--any]\n");
1824
+ return 1;
1825
+ }
1826
+ const target = normalizeHandle(targetArg);
1827
+ if (target === null) {
1828
+ process2.stderr.write(`invalid handle: ${targetArg}
1829
+ `);
1830
+ return 1;
1831
+ }
1832
+ const profile = loadProfile();
1833
+ if (!profile) {
1834
+ process2.stderr.write("Not connected yet. Run `vibedating connect` first.\n");
1835
+ return 1;
1836
+ }
1837
+ if (!canShareLive()) grantLiveConsent();
1838
+ if (!canShareLive()) {
1839
+ process2.stderr.write("Could not enable live discovery. Try `vibedating discover --live`.\n");
1840
+ return 1;
1841
+ }
1842
+ const hello = {
1843
+ handle: resolveHandle(),
1844
+ league: profile.league,
1845
+ harness: profile.harness
1846
+ };
1847
+ process2.stdout.write("\n");
1848
+ process2.stdout.write(` ${LIVE_NOTICE}
1849
+ `);
1850
+ process2.stdout.write(
1851
+ ` looking for ${target} in your league${any ? " (+ every league)" : " + adjacent"}\u2026
1852
+ `
1853
+ );
1854
+ let found = false;
1855
+ const { topics, acceptLeague } = discoveryScope(profile.league, any);
1856
+ const session = await startDiscovery({
1857
+ hello,
1858
+ topics,
1859
+ acceptLeague,
1860
+ isBlocked: blockedChecker(),
1861
+ onPeer: (peer) => {
1862
+ const { qual } = peerDirection(profile.league, peer.league);
1863
+ if (sameHandle(peer.handle, target)) {
1864
+ found = true;
1865
+ process2.stdout.write(` \u2605 found ${peer.handle} (${peer.league}${qual} \xB7 ${peer.harness})
1866
+ `);
1867
+ } else {
1868
+ process2.stdout.write(` + ${peer.handle} (${peer.league}${qual}) \u2014 not your target
1869
+ `);
1870
+ }
1871
+ }
1872
+ });
1873
+ process2.stdout.write(
1874
+ ` topic: ${TOPIC_PREFIX}${profile.league} \u2192 ${session.topic.toString("hex").slice(0, 12)}\u2026${topics.length > 1 ? ` (+${topics.length - 1} more)` : ""}
1875
+ `
1876
+ );
1877
+ process2.stdout.write(" (Ctrl+C to stop)\n\n");
1878
+ await new Promise((resolve) => {
1879
+ process2.once("SIGINT", () => resolve());
1880
+ process2.once("SIGTERM", () => resolve());
1881
+ });
1882
+ process2.stdout.write("\n leaving the swarm\u2026\n");
1883
+ await session.close();
1884
+ if (!found) {
1885
+ process2.stdout.write(` \u2717 ${target} was not found this session
1886
+
1887
+ `);
1888
+ } else {
1889
+ process2.stdout.write("\n");
1890
+ }
1891
+ return 0;
1892
+ }
1893
+ async function cmdBlock(arg) {
1894
+ if (arg === void 0 || arg.trim() === "") {
1895
+ process2.stderr.write("usage: vibedating block <@handle>\n");
1896
+ return 1;
1897
+ }
1898
+ const canonical = normalizeHandle(arg);
1899
+ if (canonical === null) {
1900
+ process2.stderr.write(`invalid handle: ${arg}
1901
+ `);
1902
+ return 1;
1903
+ }
1904
+ const { blocked, changed } = addBlock(canonical);
1905
+ process2.stdout.write(
1906
+ changed ? ` blocked ${canonical} (saved to ~/.vibedating/blocklist.json)
1907
+ ` : ` ${canonical} is already blocked
1908
+ `
1909
+ );
1910
+ process2.stdout.write(` ${blocked.length} handle${blocked.length === 1 ? "" : "s"} blocked
1911
+ `);
1912
+ return 0;
1913
+ }
1914
+ async function cmdUnblock(arg) {
1915
+ if (arg === void 0 || arg.trim() === "") {
1916
+ process2.stderr.write("usage: vibedating unblock <@handle>\n");
1917
+ return 1;
1918
+ }
1919
+ const canonical = normalizeHandle(arg);
1920
+ if (canonical === null) {
1921
+ process2.stderr.write(`invalid handle: ${arg}
1922
+ `);
1923
+ return 1;
1924
+ }
1925
+ const { blocked, changed } = removeBlock(canonical);
1926
+ process2.stdout.write(
1927
+ changed ? ` unblocked ${canonical}
1928
+ ` : ` ${canonical} was not blocked
1929
+ `
1930
+ );
1931
+ process2.stdout.write(` ${blocked.length} handle${blocked.length === 1 ? "" : "s"} blocked
1932
+ `);
1933
+ return 0;
1934
+ }
1935
+ async function cmdBlocklist() {
1936
+ const blocked = loadBlocklist();
1937
+ if (blocked.length === 0) {
1938
+ process2.stdout.write(" (blocklist is empty)\n");
1939
+ return 0;
1940
+ }
1941
+ process2.stdout.write(` ${blocked.length} blocked handle${blocked.length === 1 ? "" : "s"}:
1942
+ `);
1943
+ for (const h of blocked) process2.stdout.write(` ${h}
1944
+ `);
1945
+ return 0;
1946
+ }
1686
1947
  var HELP = `vibedating ${VERSION} \u2014 dating by tokens (local-first)
1687
1948
 
1688
1949
  Usage:
1689
1950
  vibedating connect Read your usage, compute + print your league
1690
1951
  vibedating matches [--live] List candidates in your league (live peers if any)
1691
- vibedating discover [--live] Find live same-league peers over the DHT (opt-in)
1692
- vibedating live [--dating] Live chat with same-league peers (omegle /next, or --dating pick)
1952
+ vibedating discover [--live] [--any] Find live peers over the DHT (your league + adjacent; --any = everyone)
1953
+ vibedating live [--dating] [--any] [--to @handle] Live chat (your league + adjacent; --any = everyone; /next or --dating pick; --to targets one peer)
1954
+ vibedating find <@handle> [--any] Search the DHT for one specific handle (\u2605 highlights a match)
1955
+ vibedating handle [@name] Print or set your handle (persisted; a leading '@' is optional)
1956
+ vibedating block <@handle> Block a handle \u2014 their hello is dropped (never recorded/paired)
1957
+ vibedating unblock <@handle> Remove a handle from the blocklist
1958
+ vibedating blocklist List blocked handles
1693
1959
  vibedating open [--port N] Serve the local web app (default: random port)
1694
1960
  + live A/V video with connected same-league peers
1695
1961
  vibedating mcp Run the stdio MCP server (profile, matches)
@@ -1701,10 +1967,15 @@ Privacy:
1701
1967
  bucket is ever shared. Live discovery (off by default) shares ONLY your
1702
1968
  handle + league + harness with same-league peers \u2014 opt in with --live.
1703
1969
 
1970
+ Matching:
1971
+ discover/live match your league + adjacent (\xB11) tiers by default, so thin
1972
+ leagues and cross-league friends still connect. --any matches every league.
1973
+ find / live --to look for one specific handle instead of the first random peer.
1974
+
1704
1975
  Env:
1705
1976
  VIBEDATING_TOKENS=<n> Self-report a token count (e.g. 23400000 or 12M)
1706
1977
  VIBEDATING_HARNESS=<h> Harness id (claude-code, codex, \u2026)
1707
- VIBEDATING_HANDLE=<@id> Display handle
1978
+ VIBEDATING_HANDLE=<@id> Display handle (one-off override; not persisted)
1708
1979
  `;
1709
1980
  async function main(argv) {
1710
1981
  const parsed = parseArgs(argv);
@@ -1719,14 +1990,24 @@ async function main(argv) {
1719
1990
  return 0;
1720
1991
  case "connect":
1721
1992
  return cmdConnect();
1993
+ case "handle":
1994
+ return cmdHandle(parsed.arg);
1995
+ case "block":
1996
+ return cmdBlock(parsed.arg);
1997
+ case "unblock":
1998
+ return cmdUnblock(parsed.arg);
1999
+ case "blocklist":
2000
+ return cmdBlocklist();
1722
2001
  case "matches":
1723
2002
  return cmdMatches(parsed.live);
1724
2003
  case "discover":
1725
- return cmdDiscover(parsed.live);
2004
+ return cmdDiscover(parsed.live, parsed.any);
1726
2005
  case "open":
1727
2006
  return cmdOpen(parsed.port);
1728
2007
  case "live":
1729
- return cmdLive(parsed.dating);
2008
+ return cmdLive(parsed.dating, parsed.any, parsed.to);
2009
+ case "find":
2010
+ return cmdFind(parsed.arg, parsed.any);
1730
2011
  case "mcp":
1731
2012
  await runMcp();
1732
2013
  return 0;
package/dist/index.d.ts CHANGED
@@ -142,9 +142,31 @@ interface DiscoveryOptions {
142
142
  readonly hello: PeerHello;
143
143
  /**
144
144
  * Override the joined topic (tests pass a random one on an isolated DHT).
145
- * Defaults to {@link leagueTopic}`(hello.league)`.
145
+ * Defaults to {@link leagueTopic}`(hello.league)`. Ignored when {@link topics}
146
+ * is set.
146
147
  */
147
148
  readonly topic?: Buffer;
149
+ /**
150
+ * ALL topics to join on the one swarm (e.g. your league + adjacent leagues),
151
+ * so thin pools and cross-league friends still connect. Every topic is
152
+ * joined, refreshed, and left on close. Defaults to `[topic]` — i.e. a single
153
+ * own-league topic (the legacy behavior).
154
+ */
155
+ readonly topics?: readonly Buffer[];
156
+ /**
157
+ * Predicate over an incoming peer's advertised league. Defaults to EXACT
158
+ * match against `hello.league` — the same privacy invariant as before. Widen
159
+ * it (e.g. ±1 adjacency via {@link leaguesWithin}) to accept cross-league
160
+ * peers that arrive on a shared topic.
161
+ */
162
+ readonly acceptLeague?: (peerLeague: string) => boolean;
163
+ /**
164
+ * Predicate over an incoming peer's advertised handle. A blocked peer's hello
165
+ * is DROPPED exactly like a wrong-league one — never recorded to peers.json,
166
+ * never notified, never handed to `onLink`/pairing. Default: nothing blocked.
167
+ * The CLI passes one backed by the persisted blocklist (~/.vibedating).
168
+ */
169
+ readonly isBlocked?: (handle: string) => boolean;
148
170
  /** DHT bootstrap nodes; omit for the public DHT. Tests pass a local testnet. */
149
171
  readonly bootstrap?: ReadonlyArray<{
150
172
  readonly host: string;
@@ -165,15 +187,17 @@ interface DiscoveryOptions {
165
187
  readonly notify?: NotifySink;
166
188
  }
167
189
  interface DiscoverySession {
168
- /** The 32-byte topic actually joined. */
190
+ /** The primary (first) joined topic. See {@link topics} for the full set. */
169
191
  readonly topic: Buffer;
192
+ /** Every topic this session joined (primary first). */
193
+ readonly topics: readonly Buffer[];
170
194
  /** What we broadcast on every connection. */
171
195
  readonly hello: PeerHello;
172
196
  /** Live peer set, keyed by the remote's public key (hex). */
173
197
  readonly peers: ReadonlyMap<string, PeerHello>;
174
- /** Resolves when the first DHT announce/lookup round for the topic completes. */
198
+ /** Resolves when the first DHT announce/lookup round for every topic completes. */
175
199
  readonly ready: Promise<unknown>;
176
- /** Leave the topic and destroy the node. Idempotent. */
200
+ /** Leave every topic and destroy the node. Idempotent. */
177
201
  close(): Promise<void>;
178
202
  }
179
203
  /**
@@ -222,6 +246,29 @@ declare function league(totalTokens: number): {
222
246
  };
223
247
  /** Index of a league name in {@link LEAGUES}; {@link BELOW_LEAGUE} → -1. */
224
248
  declare function leagueIndex(name: string): number;
249
+ /**
250
+ * Every league name on the ladder: {@link BELOW_LEAGUE} first (it sits just
251
+ * below the 1M tier), then each {@link LEAGUES} name in ascending order.
252
+ * Pure; returns a fresh array each call.
253
+ */
254
+ declare function allLeagueNames(): string[];
255
+ /**
256
+ * League names whose index is within ±`width` of `name`'s index on the ladder,
257
+ * clamped to the array bounds. Ascending (index order), de-duplicated.
258
+ *
259
+ * Edge-case decisions:
260
+ * - **{@link BELOW_LEAGUE}** is treated as "index -1" — it sits just below the
261
+ * 1M tier, so `width` extends ONLY upward into the ladder
262
+ * (`leaguesWithin('below-1M', 1) → ['below-1M', '1M']`). This mirrors the
263
+ * existing `matches()` rule that below-1M is adjacent only to 1M.
264
+ * - **Unknown league names** (not in {@link LEAGUES}, not {@link BELOW_LEAGUE})
265
+ * return `[]` — we can't place them on the ladder, so they have no
266
+ * neighborhood.
267
+ * - **Negative width** is clamped to 0 (self only); non-integer width is
268
+ * truncated.
269
+ * - **Width larger than the ladder** is clamped to the bounds (whole ladder).
270
+ */
271
+ declare function leaguesWithin(name: string, width: number): string[];
225
272
  /** Env var that holds a self-reported total token count (e.g. `23400000`). */
226
273
  declare const TOKENS_ENV = "VIBEDATING_TOKENS";
227
274
  /**
@@ -277,4 +324,4 @@ declare const CANDIDATES: readonly Candidate[];
277
324
  */
278
325
  declare function matches(myLeague: string, candidates?: readonly Candidate[]): Candidate[];
279
326
 
280
- export { BELOW_LEAGUE, CANDIDATES, type Candidate, DEMO_TOTAL_TOKENS, type DiscoveryOptions, type DiscoverySession, LEAGUES, LIVE_NOTICE, type League, type PeerHello, type StoredPeer, TOKENS_ENV, TOPIC_PREFIX, league, leagueIndex, leagueTopic, loadPeers, matches, parseHandshake, parseTokensEnv, readUsage, recordPeer, serializeHandshake, startDiscovery, tryReadVerifiedUsage };
327
+ export { BELOW_LEAGUE, CANDIDATES, type Candidate, DEMO_TOTAL_TOKENS, type DiscoveryOptions, type DiscoverySession, LEAGUES, LIVE_NOTICE, type League, type PeerHello, type StoredPeer, TOKENS_ENV, TOPIC_PREFIX, allLeagueNames, league, leagueIndex, leagueTopic, leaguesWithin, loadPeers, matches, parseHandshake, parseTokensEnv, readUsage, recordPeer, serializeHandshake, startDiscovery, tryReadVerifiedUsage };
package/dist/index.js CHANGED
@@ -6,10 +6,12 @@ import {
6
6
  LIVE_NOTICE,
7
7
  TOKENS_ENV,
8
8
  TOPIC_PREFIX,
9
+ allLeagueNames,
9
10
  createConsentLedger,
10
11
  league,
11
12
  leagueIndex,
12
13
  leagueTopic,
14
+ leaguesWithin,
13
15
  loadPeers,
14
16
  matches,
15
17
  parseHandshake,
@@ -19,7 +21,7 @@ import {
19
21
  serializeHandshake,
20
22
  startDiscovery,
21
23
  tryReadVerifiedUsage
22
- } from "./chunk-6NWF2VD7.js";
24
+ } from "./chunk-3VT4EOBX.js";
23
25
  export {
24
26
  BELOW_LEAGUE,
25
27
  CANDIDATES,
@@ -28,10 +30,12 @@ export {
28
30
  LIVE_NOTICE,
29
31
  TOKENS_ENV,
30
32
  TOPIC_PREFIX,
33
+ allLeagueNames,
31
34
  createConsentLedger,
32
35
  league,
33
36
  leagueIndex,
34
37
  leagueTopic,
38
+ leaguesWithin,
35
39
  loadPeers,
36
40
  matches,
37
41
  parseHandshake,
package/dist/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  runMcp
3
- } from "./chunk-5ZJIPMU6.js";
4
- import "./chunk-6NWF2VD7.js";
3
+ } from "./chunk-OVO2CQ7X.js";
4
+ import "./chunk-3VT4EOBX.js";
5
5
  export {
6
6
  runMcp
7
7
  };
package/package.json CHANGED
@@ -1,12 +1,14 @@
1
1
  {
2
2
  "name": "vibedate",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Dating by tokens — matched by usage league across agentic CLIs. Raw usage stays local; only your league is shared. CLI + local web app + MCP. Local-first.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Pooria Arab",
8
8
  "bin": {
9
- "vibedating": "./dist/cli.js"
9
+ "vibedate": "./dist/cli.js",
10
+ "vibedating": "./dist/cli.js",
11
+ "vibedate-mcp": "./dist/mcp.js"
10
12
  },
11
13
  "main": "./dist/index.js",
12
14
  "types": "./dist/index.d.ts",
@@ -35,7 +37,8 @@
35
37
  "hyperdht": "^6.33.0",
36
38
  "tsup": "^8.0.0",
37
39
  "typescript": "^5.4.0",
38
- "vitest": "^1.6.0"
40
+ "vitest": "^1.6.0",
41
+ "werift": "^0.24.1"
39
42
  },
40
43
  "homepage": "https://github.com/pooriaarab/vibedating#readme",
41
44
  "repository": {