vibedate 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,12 @@ Part of the **Vibe Suite** — companion tools for agentic coding CLIs. Ships as
9
9
  **CLI + npm package + MCP server**, plus a local web app. Built on
10
10
  [`@pooriaarab/vibe-core`](https://www.npmjs.com/package/@pooriaarab/vibe-core).
11
11
 
12
+ ## Demo
13
+
14
+ [▶ Watch the launch video](branding/launch-video.mp4) — she matched with the man of her dreams. He was coding in a basement.
15
+
16
+ https://github.com/pooriaarab/vibedating/raw/main/branding/launch-video.mp4
17
+
12
18
  > **Local-first.** Raw token usage is read and stored on your own machine and
13
19
  > **never leaves it.** Only the coarse league *bucket* is ever shared — never the
14
20
  > raw number, never per-project breakdowns. Live matching is peer-to-peer over a
@@ -62,8 +68,15 @@ VIBEDATING_TOKENS=23400000 vibedating connect # also accepts 12M / 1.2B / 500k
62
68
 
63
69
  ```
64
70
  vibedating connect Read usage, compute + print your league
65
- vibedating matches [--live] List candidates in your league (live peers if any)
71
+ (first run auto-assigns a memetic handle never @you)
72
+ vibedating matches [--live] List candidates in your league (live peers if any,
73
+ with last-message time)
66
74
  vibedating discover [--live] Find live same-league peers over the DHT (opt-in)
75
+ vibedating live [--keep-alive] Live text chat (--keep-alive / non-TTY survives stdin EOF)
76
+ vibedating handle [@name] Print or set your handle
77
+ vibedating daemon [start|stop|status|install|uninstall]
78
+ Notify-only background daemon — alerts on NEW matches,
79
+ never opens chat/video; install = run on login (launchd/systemd)
67
80
  vibedating open [--port N] Serve the local web app (default: random free port)
68
81
  vibedating mcp Run the stdio MCP server (tools: profile, matches)
69
82
  vibedating --version
@@ -2,7 +2,7 @@ import {
2
2
  CANDIDATES,
3
3
  loadProfile,
4
4
  matches
5
- } from "./chunk-43IYNWES.js";
5
+ } from "./chunk-VZXPYU2C.js";
6
6
 
7
7
  // src/mcp.ts
8
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -388,6 +388,44 @@ function classifyHelloIdentity(hello) {
388
388
  if (hello.nonce === void 0 || hello.sig === void 0) return "drop";
389
389
  return verifyHelloClaims(hello, { pubkey: hello.pubkey, nonce: hello.nonce, sig: hello.sig }) ? "verified" : "drop";
390
390
  }
391
+ var NOSTR_FILE = "nostr.json";
392
+ function nostrPath(dir) {
393
+ return path2.join(dir, NOSTR_FILE);
394
+ }
395
+ function isStoredNostrKey(data) {
396
+ if (typeof data !== "object" || data === null) return false;
397
+ const r = data;
398
+ return typeof r["sk"] === "string" && /^[0-9a-f]{64}$/.test(r["sk"]) && typeof r["pubkey"] === "string" && /^[0-9a-f]{64}$/.test(r["pubkey"]) && typeof r["createdAt"] === "string";
399
+ }
400
+ async function loadOrCreateNostrKey(dir = defaultStateDir()) {
401
+ try {
402
+ const raw = readFileSync2(nostrPath(dir), "utf8");
403
+ const data = JSON.parse(raw);
404
+ if (isStoredNostrKey(data)) {
405
+ try {
406
+ chmodSync(nostrPath(dir), 384);
407
+ } catch {
408
+ }
409
+ return { sk: Buffer.from(data.sk, "hex"), pubkey: data.pubkey };
410
+ }
411
+ } catch {
412
+ }
413
+ const { generateSecretKey, getPublicKey } = await import("nostr-tools");
414
+ const sk = generateSecretKey();
415
+ const pubkey = getPublicKey(sk);
416
+ const skHex = Buffer.from(sk).toString("hex");
417
+ mkdirSync2(dir, { recursive: true });
418
+ writeFileSync2(
419
+ nostrPath(dir),
420
+ JSON.stringify({ sk: skHex, pubkey, createdAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n",
421
+ { encoding: "utf8", mode: 384 }
422
+ );
423
+ try {
424
+ chmodSync(nostrPath(dir), 384);
425
+ } catch {
426
+ }
427
+ return { sk, pubkey };
428
+ }
391
429
 
392
430
  // src/link.ts
393
431
  import { randomUUID as randomUUID2 } from "crypto";
@@ -656,6 +694,14 @@ function createPeerLink(socket, hello, initialBuffer = "", linkOpts = {}) {
656
694
  };
657
695
  }
658
696
 
697
+ // src/untrusted.ts
698
+ var MAX_DISPLAY_TEXT_LEN = MAX_TEXT_LEN;
699
+ var UNSAFE_DISPLAY_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g;
700
+ function sanitizePeerText(text, maxLen = MAX_DISPLAY_TEXT_LEN) {
701
+ const cleaned = text.replace(UNSAFE_DISPLAY_CHARS, "");
702
+ return cleaned.length > maxLen ? cleaned.slice(0, maxLen) : cleaned;
703
+ }
704
+
659
705
  // src/p2p.ts
660
706
  var TOPIC_PREFIX = "vibedate:";
661
707
  function leagueTopic(leagueName) {
@@ -838,7 +884,9 @@ async function startDiscovery(opts) {
838
884
  try {
839
885
  notify(
840
886
  makeEvent("match", hello.harness, process.cwd(), {
841
- summary: `matched with ${peer.handle} - LIVE SAME LEAGUE`,
887
+ // AEGIS-lite: the handle is untrusted wire data — sanitized for
888
+ // display (the structured `handle` field below stays verbatim).
889
+ summary: `matched with ${sanitizePeerText(peer.handle)} - LIVE SAME LEAGUE`,
842
890
  handle: peer.handle,
843
891
  league: peer.league,
844
892
  harness: peer.harness
@@ -899,6 +947,204 @@ async function startDiscovery(opts) {
899
947
  };
900
948
  }
901
949
 
950
+ // src/relay.ts
951
+ import { createHash as createHash2 } from "crypto";
952
+ var DEFAULT_RELAYS = [
953
+ "wss://relay.damus.io",
954
+ "wss://nostr.mom",
955
+ "wss://relay.snort.social"
956
+ ];
957
+ var VIBEDATE_MESSAGE_KIND = 4;
958
+ var VIBEDATE_PRESENCE_KIND = 30078;
959
+ var PRESENCE_D_TAG = "vibedating";
960
+ var PRESENCE_MARKER = JSON.stringify({ app: "vibedating", v: 1 });
961
+ var ED25519_TAG = "ed25519";
962
+ function conversationTag(myEd25519Hex, peerEd25519Hex) {
963
+ const lo = myEd25519Hex < peerEd25519Hex ? myEd25519Hex : peerEd25519Hex;
964
+ const hi = myEd25519Hex < peerEd25519Hex ? peerEd25519Hex : myEd25519Hex;
965
+ return createHash2("sha256").update(`vibedate:nostr:${lo}:${hi}`, "utf8").digest("hex");
966
+ }
967
+ var nostrPromise;
968
+ function nostr() {
969
+ if (!nostrPromise) nostrPromise = import("nostr-tools");
970
+ return nostrPromise;
971
+ }
972
+ function tagValue(event, name) {
973
+ for (const tag of event.tags) {
974
+ if (tag[0] === name && typeof tag[1] === "string") return tag[1];
975
+ }
976
+ return void 0;
977
+ }
978
+ async function createNostrRelayLink(opts) {
979
+ const { finalizeEvent, nip04 } = await nostr();
980
+ const { myNostr, myEd25519Hex, peerEd25519Hex, hello, transport } = opts;
981
+ const convTag = conversationTag(myEd25519Hex, peerEd25519Hex);
982
+ const messageCbs = /* @__PURE__ */ new Set();
983
+ const closeCbs = /* @__PURE__ */ new Set();
984
+ const mediaCbs = /* @__PURE__ */ new Set();
985
+ const signalCbs = /* @__PURE__ */ new Set();
986
+ let closed = false;
987
+ let peerNostrPubkey;
988
+ const pending = [];
989
+ const publish = (template) => {
990
+ let event;
991
+ try {
992
+ event = finalizeEvent(
993
+ { ...template, created_at: Math.floor(Date.now() / 1e3) },
994
+ myNostr.sk
995
+ );
996
+ } catch {
997
+ return;
998
+ }
999
+ try {
1000
+ transport.publish(event);
1001
+ } catch {
1002
+ }
1003
+ };
1004
+ const publishPresence = () => {
1005
+ publish({
1006
+ kind: VIBEDATE_PRESENCE_KIND,
1007
+ content: PRESENCE_MARKER,
1008
+ tags: [
1009
+ ["d", PRESENCE_D_TAG],
1010
+ ["t", convTag],
1011
+ [ED25519_TAG, myEd25519Hex]
1012
+ ]
1013
+ });
1014
+ };
1015
+ const sendEncrypted = (text, recipientPubkey) => {
1016
+ let ciphertext;
1017
+ try {
1018
+ ciphertext = nip04.encrypt(myNostr.sk, recipientPubkey, text);
1019
+ } catch {
1020
+ return;
1021
+ }
1022
+ publish({
1023
+ kind: VIBEDATE_MESSAGE_KIND,
1024
+ content: ciphertext,
1025
+ tags: [
1026
+ ["t", convTag],
1027
+ ["p", recipientPubkey],
1028
+ [ED25519_TAG, myEd25519Hex]
1029
+ ]
1030
+ });
1031
+ };
1032
+ const flushPending = () => {
1033
+ if (peerNostrPubkey === void 0) return;
1034
+ while (pending.length > 0) {
1035
+ const text = pending.shift();
1036
+ if (text !== void 0) sendEncrypted(text, peerNostrPubkey);
1037
+ }
1038
+ };
1039
+ const unsubscribe = transport.subscribe({ "#t": [convTag] }, (event) => {
1040
+ if (closed) return;
1041
+ if (event.pubkey === myNostr.pubkey) return;
1042
+ if (event.kind === VIBEDATE_PRESENCE_KIND) {
1043
+ const claimedEd = tagValue(event, ED25519_TAG);
1044
+ if (claimedEd !== peerEd25519Hex) return;
1045
+ peerNostrPubkey = event.pubkey;
1046
+ flushPending();
1047
+ return;
1048
+ }
1049
+ if (event.kind === VIBEDATE_MESSAGE_KIND) {
1050
+ let plaintext;
1051
+ try {
1052
+ const sender = peerNostrPubkey ?? event.pubkey;
1053
+ plaintext = nip04.decrypt(myNostr.sk, sender, event.content);
1054
+ } catch {
1055
+ return;
1056
+ }
1057
+ peerNostrPubkey = event.pubkey;
1058
+ const msg = { id: event.id, text: plaintext, at: event.created_at * 1e3 };
1059
+ for (const cb of messageCbs) cb(msg);
1060
+ return;
1061
+ }
1062
+ });
1063
+ publishPresence();
1064
+ return {
1065
+ hello,
1066
+ send(text) {
1067
+ if (closed) return;
1068
+ if (peerNostrPubkey === void 0) {
1069
+ pending.push(text);
1070
+ return;
1071
+ }
1072
+ sendEncrypted(text, peerNostrPubkey);
1073
+ },
1074
+ // ponytail: chunked media over the relay (mirror link.ts media frames as
1075
+ // kind-4 payloads). For v0 a relay link carries text only.
1076
+ async sendMedia() {
1077
+ return { id: "", size: 0 };
1078
+ },
1079
+ // ponytail: relay WebRTC signaling (rtc-offer/answer/ice as kind-4 payloads)
1080
+ // so A/V could also fall back through the relay. v0: text only.
1081
+ sendSignal() {
1082
+ },
1083
+ onMessage(cb) {
1084
+ messageCbs.add(cb);
1085
+ },
1086
+ onMedia(cb) {
1087
+ mediaCbs.add(cb);
1088
+ },
1089
+ onSignal(cb) {
1090
+ signalCbs.add(cb);
1091
+ },
1092
+ onClose(cb) {
1093
+ closeCbs.add(cb);
1094
+ },
1095
+ close() {
1096
+ if (closed) return;
1097
+ closed = true;
1098
+ try {
1099
+ unsubscribe();
1100
+ } catch {
1101
+ }
1102
+ try {
1103
+ transport.close();
1104
+ } catch {
1105
+ }
1106
+ }
1107
+ };
1108
+ }
1109
+ async function createNostrPoolTransport(urls = DEFAULT_RELAYS) {
1110
+ const { SimplePool } = await nostr();
1111
+ const pool = new SimplePool();
1112
+ const relays = [...urls];
1113
+ return {
1114
+ publish(event) {
1115
+ try {
1116
+ const results = pool.publish(relays, event);
1117
+ Promise.allSettled(results).catch(() => {
1118
+ });
1119
+ } catch {
1120
+ }
1121
+ },
1122
+ subscribe(filter, onEvent) {
1123
+ const closer = pool.subscribeMany(
1124
+ relays,
1125
+ filter,
1126
+ { onevent: (e) => onEvent(e) }
1127
+ );
1128
+ return () => {
1129
+ try {
1130
+ closer.close();
1131
+ } catch {
1132
+ }
1133
+ };
1134
+ },
1135
+ close() {
1136
+ try {
1137
+ pool.close(relays);
1138
+ } catch {
1139
+ }
1140
+ try {
1141
+ pool.destroy();
1142
+ } catch {
1143
+ }
1144
+ }
1145
+ };
1146
+ }
1147
+
902
1148
  // src/index.ts
903
1149
  var LEAGUES = [
904
1150
  { name: "1M", min: 1e6, max: 4999999 },
@@ -1036,12 +1282,16 @@ function matches(myLeague, candidates = CANDIDATES) {
1036
1282
 
1037
1283
  export {
1038
1284
  parseFrame,
1285
+ defaultStateDir,
1039
1286
  connectProfile,
1040
1287
  loadProfile,
1041
1288
  grantLiveConsent,
1042
1289
  canShareLive,
1290
+ DEFAULT_HANDLE,
1291
+ MAX_HANDLE_LEN,
1043
1292
  sameHandle,
1044
1293
  normalizeHandle,
1294
+ loadHandle,
1045
1295
  saveHandle,
1046
1296
  resolveHandle,
1047
1297
  loadBlocklist,
@@ -1053,6 +1303,8 @@ export {
1053
1303
  signHelloClaims,
1054
1304
  verifyHelloClaims,
1055
1305
  classifyHelloIdentity,
1306
+ loadOrCreateNostrKey,
1307
+ sanitizePeerText,
1056
1308
  TOPIC_PREFIX,
1057
1309
  leagueTopic,
1058
1310
  LIVE_NOTICE,
@@ -1062,6 +1314,12 @@ export {
1062
1314
  recordPeer,
1063
1315
  recordPeerMessage,
1064
1316
  startDiscovery,
1317
+ DEFAULT_RELAYS,
1318
+ VIBEDATE_MESSAGE_KIND,
1319
+ VIBEDATE_PRESENCE_KIND,
1320
+ conversationTag,
1321
+ createNostrRelayLink,
1322
+ createNostrPoolTransport,
1065
1323
  LEAGUES,
1066
1324
  BELOW_LEAGUE,
1067
1325
  league,
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' | 'find' | 'handle' | 'block' | 'unblock' | 'blocklist' | 'mcp' | 'help' | 'version' | null;
3
+ type Command = 'connect' | 'matches' | 'discover' | 'open' | 'live' | 'find' | 'handle' | 'block' | 'unblock' | 'blocklist' | 'daemon' | '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". */
@@ -15,11 +15,32 @@ interface ParsedArgs {
15
15
  readonly arg: string | undefined;
16
16
  /** `live --to <@handle>`: targeted match — auto-open that specific peer. */
17
17
  readonly to: string | undefined;
18
+ /** `live --keep-alive`: stay in the swarm after stdin EOF. Default false. */
19
+ readonly keepAlive: boolean;
20
+ /**
21
+ * `live --via-relay` / `discover --via-relay`: route through public Nostr relays
22
+ * (NIP-04 e2e) when direct hyperdht hole-punching fails. v0 explicit opt-in;
23
+ * auto-fallback-on-timeout is a follow-up. Default false.
24
+ */
25
+ readonly viaRelay: boolean;
18
26
  }
19
27
  /**
20
28
  * Parse argv (the slice AFTER the program name) into a command + options.
21
29
  * Pure: no IO, no process access — trivially unit-testable.
22
30
  */
23
31
  declare function parseArgs(argv: readonly string[]): ParsedArgs;
32
+ /**
33
+ * Compact relative time for local lists: an ISO timestamp → "just now" /
34
+ * "5m ago" / "3h ago" / "2d ago". Unparseable input → "unknown". Pure.
35
+ */
36
+ declare function formatAgo(iso: string, now?: Date): string;
37
+ /**
38
+ * Whether `live` should stay in the swarm after stdin closes. An explicit
39
+ * `--keep-alive` always keeps it; otherwise AUTO-detect: a non-TTY stdin
40
+ * (piped, `</dev/null`, backgrounded) hits EOF immediately, and exiting there
41
+ * would kill an unattended session — so it stays up until SIGINT/SIGTERM.
42
+ * Interactive TTY behavior is unchanged: Ctrl+D / EOF still exits.
43
+ */
44
+ declare function shouldKeepAlive(flag: boolean, stdinIsTTY: boolean | undefined): boolean;
24
45
 
25
- export { type Command, type ParsedArgs, parseArgs };
46
+ export { type Command, type ParsedArgs, formatAgo, parseArgs, shouldKeepAlive };