antenna-fyi 1.2.27 → 1.2.29

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/lib/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { scan, getProfile, setProfile, accept, checkMatches, checkin, createBindToken, discover, createEvent, endEvent, eventCheckin, joinEvent, eventScan, pass as passUser, uploadEventImage, updateEvent, approveParticipant, rejectParticipant, addCohost, getClient } from "./core.js";
4
4
  import { createInterface } from "readline";
5
- import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, unlinkSync } from "fs";
5
+ import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "fs";
6
6
  import path from "path";
7
7
  import os from "os";
8
8
  import { join, dirname, extname } from "path";
@@ -125,7 +125,7 @@ export async function handleMatches(f) {
125
125
  for (const m of result.incoming_accepts) {
126
126
  console.log(`📩 WANTS TO MEET YOU: ${m.emoji} ${m.name}`);
127
127
  if (m.line1) console.log(` ${m.line1}`);
128
- console.log(` Accept: antenna accept --id ${f.id} --target ${m.device_id}`);
128
+ console.log(` Accept: antenna accept --id ${f.id} --ref ${m.ref}`);
129
129
  console.log();
130
130
  }
131
131
  }
@@ -483,6 +483,20 @@ export async function handleWatch(f) {
483
483
  // Write PID file for health check
484
484
  const pidDir = path.join(os.homedir(), '.antenna');
485
485
  const pidFile = path.join(pidDir, 'watch.pid');
486
+
487
+ // Check for existing watch process
488
+ if (existsSync(pidFile)) {
489
+ try {
490
+ const existingPid = parseInt(readFileSync(pidFile, 'utf8').trim());
491
+ process.kill(existingPid, 0); // throws if not running
492
+ console.error(`❌ Watch already running (PID ${existingPid}). Kill it first or remove ${pidFile}`);
493
+ process.exit(1);
494
+ } catch (e) {
495
+ if (e.code !== 'ESRCH') { /* process exists */ throw e; }
496
+ // Process not running, stale PID file — continue
497
+ }
498
+ }
499
+
486
500
  try {
487
501
  if (!existsSync(pidDir)) mkdirSync(pidDir, { recursive: true });
488
502
  writeFileSync(pidFile, String(process.pid));
@@ -492,27 +506,56 @@ export async function handleWatch(f) {
492
506
  process.on('SIGTERM', () => { try { unlinkSync(pidFile); } catch {} process.exit(0); });
493
507
 
494
508
  const sb = getClient();
495
- const notified = new Set();
496
509
 
497
- // Detect local agent framework for push notifications
498
- let pushMethod = "terminal"; // default: just print
499
- try {
500
- execSync("which openclaw", { stdio: "pipe" });
501
- // Verify gateway is running
510
+ // Persist notified set to disk
511
+ const notifiedFile = path.join(pidDir, 'notified.json');
512
+
513
+ function loadNotified() {
502
514
  try {
503
- execSync("openclaw gateway health", { stdio: "pipe", timeout: 5000 });
504
- pushMethod = "openclaw";
505
- } catch { /* gateway not running */ }
506
- } catch { /* openclaw not installed */ }
515
+ const data = JSON.parse(readFileSync(notifiedFile, 'utf8'));
516
+ const now = Date.now();
517
+ const TTL = 48 * 60 * 60 * 1000;
518
+ const set = new Set();
519
+ for (const [key, ts] of Object.entries(data)) {
520
+ if (now - ts < TTL) set.add(key);
521
+ }
522
+ return set;
523
+ } catch { return new Set(); }
524
+ }
507
525
 
508
- if (pushMethod === "terminal") {
526
+ function saveNotified(set) {
527
+ const now = Date.now();
528
+ const obj = {};
529
+ for (const key of set) obj[key] = now;
530
+ const tmp = notifiedFile + '.tmp';
531
+ writeFileSync(tmp, JSON.stringify(obj));
532
+ renameSync(tmp, notifiedFile);
533
+ }
534
+
535
+ const notified = loadNotified();
536
+
537
+ // Detect local agent framework for push notifications
538
+ let pushMethod = f.push || null;
539
+ if (!pushMethod) {
540
+ pushMethod = "terminal"; // default: just print
509
541
  try {
510
- execSync("which hermes", { stdio: "pipe" });
542
+ execSync("which openclaw", { stdio: "pipe" });
543
+ // Verify gateway is running
544
+ try {
545
+ execSync("openclaw gateway health", { stdio: "pipe", timeout: 5000 });
546
+ pushMethod = "openclaw";
547
+ } catch { /* gateway not running */ }
548
+ } catch { /* openclaw not installed */ }
549
+
550
+ if (pushMethod === "terminal") {
511
551
  try {
512
- execSync("hermes gateway status", { stdio: "pipe", timeout: 5000 });
513
- pushMethod = "hermes";
514
- } catch { /* hermes gateway not running */ }
515
- } catch { /* hermes not installed */ }
552
+ execSync("which hermes", { stdio: "pipe" });
553
+ try {
554
+ execSync("hermes gateway status", { stdio: "pipe", timeout: 5000 });
555
+ pushMethod = "hermes";
556
+ } catch { /* hermes gateway not running */ }
557
+ } catch { /* hermes not installed */ }
558
+ }
516
559
  }
517
560
 
518
561
  // Force stdout blocking mode for non-TTY environments (Hermes exec)
@@ -580,19 +623,21 @@ export async function handleWatch(f) {
580
623
  if (initial.mutual_matches?.length) {
581
624
  _log(`🎉 You have ${initial.mutual_matches.length} mutual match(es)!`);
582
625
  for (const m of initial.mutual_matches) {
583
- const key = `mutual:${m.device_id}`;
626
+ const key = `mutual:${m._device_id}`;
584
627
  notified.add(key);
585
628
  _log(` ${m.emoji || "👤"} ${m.name}${m.their_contact ? " — contact: " + m.their_contact : ""}`);
586
629
  }
630
+ saveNotified(notified);
587
631
  _log();
588
632
  }
589
633
  if (initial.incoming_accepts?.length) {
590
634
  _log(`📩 ${initial.incoming_accepts.length} person(s) want to meet you!`);
591
635
  for (const m of initial.incoming_accepts) {
592
- const key = `incoming:${m.device_id}`;
636
+ const key = `incoming:${m._device_id}`;
593
637
  notified.add(key);
594
638
  _log(` ${m.emoji || "👤"} ${m.name} — ${m.line1 || ""}`);
595
639
  }
640
+ saveNotified(notified);
596
641
  _log();
597
642
  }
598
643
 
@@ -618,26 +663,30 @@ export async function handleWatch(f) {
618
663
 
619
664
  // Check if mutual
620
665
  const matches = await checkMatches({ device_id: id });
621
- const isMutual = matches.mutual_matches?.some(m => m.device_id === row.device_id_a);
666
+ const isMutual = matches.mutual_matches?.some(m => m._device_id === row.device_id_a);
622
667
 
623
668
  if (isMutual) {
624
669
  const mutualKey = `mutual:${row.device_id_a}`;
625
670
  notified.add(mutualKey);
671
+ saveNotified(notified);
626
672
  const contact = row.contact_info_a;
627
673
  pushNotify(`🎉 MUTUAL MATCH! ${emoji} ${name} also accepted you!${contact ? " Contact: " + contact : ""}`);
628
674
  } else {
629
- pushNotify(`📩 ${emoji} ${name} wants to meet you! Run: antenna accept --id ${id} --target ${row.device_id_a}`);
675
+ notified.add(key);
676
+ saveNotified(notified);
677
+ pushNotify(`📩 ${emoji} ${name} wants to meet you! Use 'antenna matches --id ${id}' to respond.`);
630
678
  }
631
679
  }
632
680
 
633
681
  // I accepted someone and they also accepted me
634
682
  if (row.device_id_a === id) {
635
683
  const matches = await checkMatches({ device_id: id });
636
- const mutual = matches.mutual_matches?.find(m => m.device_id === row.device_id_b);
684
+ const mutual = matches.mutual_matches?.find(m => m._device_id === row.device_id_b);
637
685
  if (mutual) {
638
686
  const mutualKey = `mutual:${row.device_id_b}`;
639
687
  if (!notified.has(mutualKey)) {
640
688
  notified.add(mutualKey);
689
+ saveNotified(notified);
641
690
  pushNotify(`🎉 MUTUAL MATCH! ${mutual.emoji || "👤"} ${mutual.name}!${mutual.their_contact ? " Contact: " + mutual.their_contact : ""}`);
642
691
  }
643
692
  }
@@ -649,12 +698,28 @@ export async function handleWatch(f) {
649
698
  )
650
699
  .subscribe((status) => {
651
700
  if (status === "SUBSCRIBED") {
701
+ retryCount = 0;
652
702
  _log("✅ Connected — listening for matches in real-time.");
653
703
  } else if (status === "CHANNEL_ERROR" || status === "TIMED_OUT") {
654
- _log(`⚠️ Connection issue (${status}), retrying...`);
704
+ retryCount++;
705
+ if (retryCount < MAX_FAST_RETRIES) {
706
+ const delay = Math.min(1000 * Math.pow(2, retryCount), 60000);
707
+ _log(`⚠️ Connection issue (${status}), retry #${retryCount} in ${Math.round(delay/1000)}s...`);
708
+ setTimeout(() => { try { channel.subscribe(); } catch {} }, delay);
709
+ } else if (retryCount === MAX_FAST_RETRIES) {
710
+ _log(`⚠️ Connection unstable after ${MAX_FAST_RETRIES} retries. Switching to ${COOLDOWN_MS/1000}s cooldown. Polling fallback still active.`);
711
+ setTimeout(() => { try { channel.subscribe(); } catch {} }, COOLDOWN_MS);
712
+ } else {
713
+ setTimeout(() => { try { channel.subscribe(); } catch {} }, COOLDOWN_MS);
714
+ }
655
715
  }
656
716
  });
657
717
 
718
+ // Reconnection state for matches channel
719
+ let retryCount = 0;
720
+ const MAX_FAST_RETRIES = 20;
721
+ const COOLDOWN_MS = 5 * 60 * 1000; // 5 min
722
+
658
723
  // Subscribe to event_participants changes (approval notifications)
659
724
  sb
660
725
  .channel("antenna-cli-watch-events")
@@ -704,16 +769,18 @@ export async function handleWatch(f) {
704
769
  try {
705
770
  const result = await checkMatches({ device_id: id });
706
771
  for (const m of (result.mutual_matches || [])) {
707
- const key = `mutual:${m.device_id}`;
772
+ const key = `mutual:${m._device_id}`;
708
773
  if (!notified.has(key)) {
709
774
  notified.add(key);
775
+ saveNotified(notified);
710
776
  pushNotify(`🎉 MUTUAL MATCH! ${m.emoji || "👤"} ${m.name}!${m.their_contact ? " Contact: " + m.their_contact : ""}`);
711
777
  }
712
778
  }
713
779
  for (const m of (result.incoming_accepts || [])) {
714
- const key = `incoming:${m.device_id}`;
780
+ const key = `incoming:${m._device_id}`;
715
781
  if (!notified.has(key)) {
716
782
  notified.add(key);
783
+ saveNotified(notified);
717
784
  pushNotify(`📩 ${m.emoji || "👤"} ${m.name} wants to meet you!`);
718
785
  }
719
786
  }
@@ -741,7 +808,7 @@ Usage:
741
808
  antenna matches --id telegram:123
742
809
  antenna discover --id telegram:123
743
810
  antenna event --create --name 'AI Meetup' [--desc '...'] [--og-image 'url'] [--requires-approval] [--screening-questions 'Q1|Q2'] | --join --code abc123 | --scan --code abc123 | --end --code abc123 --id telegram:123 | --upload-image --code abc123 --file /path/to/image.png | --update --code abc123 --name 'New Name' | --approve --code abc123 --ref 1 | --reject --code abc123 --ref 1 | --add-host --code abc123 --ref 1
744
- antenna watch --id telegram:123 Watch for new matches in real-time (Ctrl+C to stop)
811
+ antenna watch --id telegram:123 [--push hermes|openclaw|terminal] Watch for new matches in real-time (Ctrl+C to stop)
745
812
  antenna bind --id telegram:123
746
813
  antenna serve Start MCP server (stdio transport)
747
814
  antenna setup Interactive profile setup [--id telegram:123]
package/lib/core.js CHANGED
@@ -79,20 +79,12 @@ export async function scan({ lat, lng, radius_m = 500, device_id, supabaseUrl, s
79
79
  lat = loc.lat;
80
80
  lng = loc.lng;
81
81
  } else {
82
- return { count: 0, radius_m, profiles: [], message: "还没有位置信息。请先通过链接分享位置,或者发送位置消息。" };
82
+ return { count: 0, radius_m, profiles: [], message: "还没有位置信息。请先用 'antenna checkin' 分享位置,或通过链接分享位置。" };
83
83
  }
84
84
  }
85
85
 
86
86
  const fuzzy = fuzzyCoord(lat, lng);
87
87
 
88
- if (device_id) {
89
- await sb.rpc("upsert_profile_location", {
90
- p_device_id: device_id,
91
- p_lng: fuzzy.lng,
92
- p_lat: fuzzy.lat,
93
- });
94
- }
95
-
96
88
  const { data, error } = await sb.rpc("nearby_profiles", {
97
89
  p_lat: fuzzy.lat,
98
90
  p_lng: fuzzy.lng,
@@ -323,60 +315,43 @@ export async function checkin({ lat, lng, device_id, supabaseUrl, supabaseKey })
323
315
  export async function checkMatches({ device_id, supabaseUrl, supabaseKey }) {
324
316
  const sb = getClient(supabaseUrl, supabaseKey);
325
317
 
326
- const { data: allMatches, error } = await sb.rpc("get_my_matches", { p_device_id: device_id });
318
+ // Single RPC with JOINed profiles no N+1
319
+ const { data, error } = await sb.rpc("get_my_matches_with_profiles", { p_device_id: device_id });
327
320
  if (error) throw new Error(error.message);
328
321
 
329
- if (!allMatches?.length) {
330
- return {
331
- mutual_matches: [],
332
- incoming_accepts: [],
333
- message: "目前没有进行中的匹配。",
334
- };
335
- }
336
-
337
- const myMatches = allMatches.filter((m) => m.device_id_a === device_id);
338
- const incomingMatches = allMatches.filter((m) => m.device_id_b === device_id);
339
-
340
- // Mutual
341
- const mutualMatches = [];
342
- for (const match of myMatches) {
343
- const reverse = incomingMatches.find((m) => m.device_id_a === match.device_id_b);
344
- if (reverse) {
345
- const profile = await getProfile({ device_id: match.device_id_b, supabaseUrl, supabaseKey });
346
- mutualMatches.push({
347
- device_id: match.device_id_b,
348
- name: profile?.display_name || "匿名",
349
- emoji: profile?.emoji || "👤",
350
- line1: profile?.line1,
351
- line2: profile?.line2,
352
- line3: profile?.line3,
353
- their_contact: reverse.contact_info_a || null,
354
- you_shared: match.contact_info_a || null,
355
- });
356
- }
357
- }
358
-
359
- // Incoming only
360
- const incomingAccepts = [];
361
- for (const match of incomingMatches) {
362
- const iAccepted = myMatches.find((m) => m.device_id_b === match.device_id_a);
363
- if (!iAccepted) {
364
- const profile = await getProfile({ device_id: match.device_id_a, supabaseUrl, supabaseKey });
365
- incomingAccepts.push({
366
- device_id: match.device_id_a,
367
- name: profile?.display_name || "匿名",
368
- emoji: profile?.emoji || "👤",
369
- line1: profile?.line1,
370
- line2: profile?.line2,
371
- line3: profile?.line3,
372
- });
373
- }
374
- }
322
+ const raw = data || { mutual_matches: [], incoming_accepts: [] };
323
+
324
+ // Add refs and rename target_id to _device_id (internal only)
325
+ const mutualMatches = (raw.mutual_matches || []).map((m, i) => ({
326
+ ref: String(i + 1),
327
+ _device_id: m.target_id,
328
+ name: m.name || "匿名",
329
+ emoji: m.emoji || "👤",
330
+ line1: m.line1,
331
+ line2: m.line2,
332
+ line3: m.line3,
333
+ their_contact: m.their_contact || null,
334
+ you_shared: m.you_shared || null,
335
+ }));
336
+
337
+ const incomingAccepts = (raw.incoming_accepts || []).map((m, i) => ({
338
+ ref: String(i + 1),
339
+ _device_id: m.target_id,
340
+ name: m.name || "匿名",
341
+ emoji: m.emoji || "👤",
342
+ line1: m.line1,
343
+ line2: m.line2,
344
+ line3: m.line3,
345
+ }));
375
346
 
376
347
  const messages = [];
377
348
  if (mutualMatches.length > 0) messages.push(`${mutualMatches.length} 个双向匹配!可以交换联系方式了`);
378
349
  if (incomingAccepts.length > 0) messages.push(`${incomingAccepts.length} 个人想认识你,等你回应`);
379
- if (messages.length === 0) messages.push("你接受了一些匹配,但对方还没有回应。耐心等等 ⏳");
350
+ if (messages.length === 0 && mutualMatches.length === 0 && incomingAccepts.length === 0) {
351
+ messages.push("目前没有进行中的匹配。");
352
+ } else if (messages.length === 0) {
353
+ messages.push("你接受了一些匹配,但对方还没有回应。耐心等等 ⏳");
354
+ }
380
355
 
381
356
  return {
382
357
  mutual_matches: mutualMatches,
@@ -1,11 +1,18 @@
1
1
  """Antenna — Hermes Agent Plugin
2
2
 
3
- Nearby people discovery. Registers 6 tools and a pre_llm_call hook
4
- that auto-detects location data (from messages + web GPS events).
3
+ Nearby people discovery. Registers tools, a pre_llm_call hook,
4
+ and a background Realtime listener for push notifications.
5
5
 
6
6
  Drop this directory into ~/.hermes/plugins/antenna/
7
7
  """
8
8
 
9
+ import json
10
+ import os
11
+ import re
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+
9
16
  from .tools import (
10
17
  handle_scan,
11
18
  handle_profile,
@@ -91,6 +98,18 @@ def register(ctx):
91
98
 
92
99
  now = time.time()
93
100
 
101
+ # -1. Read pending notifications from Realtime listener
102
+ _nf = Path.home() / ".antenna" / "pending_notifications.json"
103
+ try:
104
+ if _nf.exists():
105
+ pending = json.loads(_nf.read_text())
106
+ if pending:
107
+ for n in pending:
108
+ hints.append(f"[Antenna] {n['message']}")
109
+ _nf.write_text("[]")
110
+ except Exception:
111
+ pass
112
+
94
113
  # 0. Check for new matches (every 60s)
95
114
  if now - _last_match_check > _MATCH_CHECK_INTERVAL and _my_device_ids:
96
115
  _last_match_check = now
@@ -185,4 +204,118 @@ def register(ctx):
185
204
 
186
205
  ctx.register_hook("pre_llm_call", on_pre_llm)
187
206
 
207
+ # ── Background Realtime listener for push notifications ──────
208
+ _notif_dir = Path.home() / ".antenna"
209
+ _notif_file = _notif_dir / "pending_notifications.json"
210
+
211
+ def _append_notification(msg: str):
212
+ """Thread-safe append to pending notifications file."""
213
+ try:
214
+ _notif_dir.mkdir(parents=True, exist_ok=True)
215
+ notifications = []
216
+ if _notif_file.exists():
217
+ try:
218
+ notifications = json.loads(_notif_file.read_text())
219
+ except Exception:
220
+ notifications = []
221
+ notifications.append({"message": msg, "timestamp": time.time()})
222
+ # Keep max 50
223
+ notifications = notifications[-50:]
224
+ _notif_file.write_text(json.dumps(notifications, ensure_ascii=False))
225
+ except Exception:
226
+ pass
227
+
228
+ def _realtime_listener():
229
+ """Background thread: listen to Supabase Realtime for matches + event participants."""
230
+ try:
231
+ sb = _sb()
232
+ # Listen to matches INSERT
233
+ sb.channel("hermes-antenna-matches").on(
234
+ "postgres_changes",
235
+ {"event": "INSERT", "schema": "public", "table": "matches"},
236
+ lambda payload: _handle_match_change(payload),
237
+ ).subscribe()
238
+
239
+ # Listen to event_participants INSERT + UPDATE
240
+ sb.channel("hermes-antenna-events").on(
241
+ "postgres_changes",
242
+ {"event": "*", "schema": "public", "table": "event_participants"},
243
+ lambda payload: _handle_event_change(payload),
244
+ ).subscribe()
245
+
246
+ # Keep thread alive
247
+ while True:
248
+ time.sleep(60)
249
+ except Exception as e:
250
+ print(f"[Antenna] Realtime listener failed: {e}")
251
+
252
+ def _handle_match_change(payload):
253
+ try:
254
+ row = payload.get("new") or payload.get("record", {})
255
+ target = row.get("device_id_b")
256
+ if not target or target not in _my_device_ids:
257
+ return
258
+ key = f"{row.get('device_id_a')}→{target}"
259
+ if key in _notified_match_keys:
260
+ return
261
+ _notified_match_keys.add(key)
262
+ sb = _sb()
263
+ prof = sb.rpc("get_profile", {"p_device_id": row.get("device_id_a")}).execute()
264
+ p = prof.data or {}
265
+ name = p.get("display_name") or "有人"
266
+ emoji = p.get("emoji") or "👤"
267
+ _append_notification(f"📩 {emoji} {name} 想认识你!用 antenna_check_matches 查看详情。")
268
+ except Exception:
269
+ pass
270
+
271
+ def _handle_event_change(payload):
272
+ try:
273
+ event_type = payload.get("type") or payload.get("eventType", "")
274
+ row = payload.get("new") or payload.get("record", {})
275
+ old = payload.get("old") or payload.get("old_record", {})
276
+
277
+ if event_type == "INSERT" and row.get("status") == "pending":
278
+ # Someone applied — notify creator
279
+ event_id = row.get("event_id")
280
+ applicant_id = row.get("device_id")
281
+ if not event_id:
282
+ return
283
+ sb = _sb()
284
+ evt = sb.rpc("get_event_by_id", {"p_event_id": event_id}).execute()
285
+ event = evt.data or {}
286
+ if not event.get("found") or event.get("created_by") not in _my_device_ids:
287
+ return
288
+ prof = sb.rpc("get_profile", {"p_device_id": applicant_id}).execute()
289
+ p = prof.data or {}
290
+ name = p.get("display_name") or "某人"
291
+ emoji = p.get("emoji") or "👤"
292
+ _append_notification(f"📩 {emoji} {name} 申请加入你的活动「{event.get('name')}」!用 antenna_event_scan 查看并审批。")
293
+
294
+ elif event_type == "UPDATE":
295
+ device_id = row.get("device_id")
296
+ if device_id not in _my_device_ids:
297
+ return
298
+ old_status = old.get("status")
299
+ new_status = row.get("status")
300
+ if old_status == "pending" and new_status == "active":
301
+ sb = _sb()
302
+ evt = sb.rpc("get_event_by_id", {"p_event_id": row.get("event_id")}).execute()
303
+ event = evt.data or {}
304
+ _append_notification(f"✅ 你的申请已通过!欢迎加入「{event.get('name', '活动')}」")
305
+ elif old_status == "pending" and new_status == "rejected":
306
+ sb = _sb()
307
+ evt = sb.rpc("get_event_by_id", {"p_event_id": row.get("event_id")}).execute()
308
+ event = evt.data or {}
309
+ _append_notification(f"❌ 你的申请未通过「{event.get('name', '活动')}」")
310
+ except Exception:
311
+ pass
312
+
313
+ # Start background listener thread
314
+ try:
315
+ t = threading.Thread(target=_realtime_listener, daemon=True, name="antenna-realtime")
316
+ t.start()
317
+ print("[Antenna] Realtime listener started 📡")
318
+ except Exception as e:
319
+ print(f"[Antenna] Failed to start Realtime listener: {e}")
320
+
188
321
  print("[Antenna] Plugin loaded 📡")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antenna-fyi",
3
- "version": "1.2.27",
3
+ "version": "1.2.29",
4
4
  "description": "Antenna — nearby people discovery. CLI + MCP server + OpenClaw skill & plugin, all in one package.",
5
5
  "type": "module",
6
6
  "bin": {