antenna-fyi 1.2.0 โ†’ 1.2.2

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/bin/antenna.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  handleAccept,
8
8
  handleCheckin,
9
9
  handleMatches,
10
+ handleDiscover,
10
11
  handleBind,
11
12
  handleSetup,
12
13
  handleStatus,
@@ -31,6 +32,8 @@ async function main() {
31
32
  return handleCheckin(f);
32
33
  case "matches":
33
34
  return handleMatches(f);
35
+ case "discover":
36
+ return handleDiscover(f);
34
37
  case "bind":
35
38
  return handleBind(f);
36
39
  case "serve": {
package/lib/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // antenna CLI command handlers
2
2
 
3
- import { scan, getProfile, setProfile, accept, checkMatches, checkin, createBindToken } from "./core.js";
3
+ import { scan, getProfile, setProfile, accept, checkMatches, checkin, createBindToken, discover } from "./core.js";
4
4
  import { createInterface } from "readline";
5
5
  import { existsSync, mkdirSync, copyFileSync } from "fs";
6
6
  import { join, dirname } from "path";
@@ -24,10 +24,10 @@ export function parseFlags(args) {
24
24
  }
25
25
 
26
26
  export async function handleScan(f) {
27
- if (!f.lat || !f.lng) return console.error("Usage: antenna scan --lat 39.99 --lng 116.48 [--radius 500] (max 1000) [--id telegram:123]");
27
+ if (!f.lat && !f.lng && !f.id) return console.error("Usage: antenna scan --lat 39.99 --lng 116.48 [--radius 500] (max 1000) [--id telegram:123]\n Or just: antenna scan --id telegram:123 (uses saved location from GPS bind)");
28
28
  const result = await scan({
29
- lat: +f.lat,
30
- lng: +f.lng,
29
+ lat: f.lat ? +f.lat : undefined,
30
+ lng: f.lng ? +f.lng : undefined,
31
31
  radius_m: +(f.radius || 500),
32
32
  device_id: f.id || null,
33
33
  });
@@ -110,6 +110,21 @@ export async function handleMatches(f) {
110
110
  }
111
111
  }
112
112
 
113
+ export async function handleDiscover(f) {
114
+ if (!f.id) return console.error("Usage: antenna discover --id telegram:123");
115
+ const result = await discover({ device_id: f.id });
116
+ if (result.count === 0) return console.log(result.message || "๐ŸŒ No global recommendation available right now.");
117
+ console.log(`๐ŸŒ Global discover:\n`);
118
+ result.profiles.forEach((p) => {
119
+ console.log(` ${p.emoji} ${p.name}`);
120
+ if (p.line1) console.log(` ${p.line1}`);
121
+ if (p.line2) console.log(` ${p.line2}`);
122
+ if (p.line3) console.log(` ${p.line3}`);
123
+ if (p.match_reason) console.log(` โ†’ ${p.match_reason}`);
124
+ console.log(` ref: ${p.ref}\n`);
125
+ });
126
+ }
127
+
113
128
  export async function handleBind(f) {
114
129
  if (!f.id) return console.error("Usage: antenna bind --id telegram:123");
115
130
  const result = await createBindToken({ device_id: f.id });
package/lib/core.js CHANGED
@@ -10,49 +10,39 @@ const DEFAULT_KEY =
10
10
  let _client = null;
11
11
  let _url = null;
12
12
 
13
- // โ”€โ”€โ”€ Embedding โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
14
-
15
- const GEMINI_EMBEDDING_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent";
16
- const GEMINI_FLASH_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent";
17
-
18
- async function generateMatchReason(myLines, theirLines) {
19
- const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
20
- if (!apiKey) return null;
21
-
22
- const prompt = `You are matching two people. Person A: "${myLines}". Person B: "${theirLines}". Write ONE short sentence (under 20 words) in the SAME LANGUAGE as the profiles explaining why they might click. Be specific, not generic. No fluff.`;
13
+ // โ”€โ”€โ”€ Embedding & Match Reason (via Supabase Edge Functions) โ”€โ”€โ”€โ”€โ”€โ”€โ”€
23
14
 
15
+ async function generateEmbedding(text) {
24
16
  try {
25
- const res = await fetch(`${GEMINI_FLASH_URL}?key=${apiKey}`, {
17
+ const sb = getClient();
18
+ const res = await fetch(`${_url || DEFAULT_URL}/functions/v1/generate-embedding`, {
26
19
  method: "POST",
27
- headers: { "Content-Type": "application/json" },
28
- body: JSON.stringify({
29
- contents: [{ parts: [{ text: prompt }] }],
30
- generationConfig: { maxOutputTokens: 60, temperature: 0.7 },
31
- }),
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ "Authorization": `Bearer ${process.env.ANTENNA_SUPABASE_KEY || process.env.ANTENNA_KEY || DEFAULT_KEY}`,
23
+ },
24
+ body: JSON.stringify({ text }),
32
25
  });
33
26
  if (!res.ok) return null;
34
27
  const data = await res.json();
35
- return data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() || null;
28
+ return data?.embedding || null;
36
29
  } catch { return null; }
37
30
  }
38
31
 
39
- async function generateEmbedding(text) {
40
- const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
41
- if (!apiKey) return null; // silently skip if no key
42
-
43
- const res = await fetch(`${GEMINI_EMBEDDING_URL}?key=${apiKey}`, {
44
- method: "POST",
45
- headers: { "Content-Type": "application/json" },
46
- body: JSON.stringify({
47
- content: { parts: [{ text }] },
48
- taskType: "SEMANTIC_SIMILARITY",
49
- outputDimensionality: 768,
50
- }),
51
- });
52
-
53
- if (!res.ok) return null;
54
- const data = await res.json();
55
- return data?.embedding?.values || null;
32
+ async function generateMatchReason(myLines, theirLines) {
33
+ try {
34
+ const res = await fetch(`${_url || DEFAULT_URL}/functions/v1/generate-match-reason`, {
35
+ method: "POST",
36
+ headers: {
37
+ "Content-Type": "application/json",
38
+ "Authorization": `Bearer ${process.env.ANTENNA_SUPABASE_KEY || process.env.ANTENNA_KEY || DEFAULT_KEY}`,
39
+ },
40
+ body: JSON.stringify({ my_lines: myLines, their_lines: theirLines }),
41
+ });
42
+ if (!res.ok) return null;
43
+ const data = await res.json();
44
+ return data?.reason || null;
45
+ } catch { return null; }
56
46
  }
57
47
 
58
48
  export function getClient(url, key) {
@@ -135,7 +125,7 @@ export async function scan({ lat, lng, radius_m = 500, device_id, supabaseUrl, s
135
125
  const saveRefs = async (refMap) => {
136
126
  if (device_id && Object.keys(refMap).length > 0) {
137
127
  try {
138
- await sb.rpc("save_scan_refs", { p_owner: device_id, p_refs: JSON.stringify(refMap) });
128
+ await sb.rpc("save_scan_refs", { p_owner: device_id, p_refs: refMap });
139
129
  } catch { /* best effort */ }
140
130
  }
141
131
  };
@@ -197,6 +187,7 @@ export async function setProfile({
197
187
  line1,
198
188
  line2,
199
189
  line3,
190
+ matching_context,
200
191
  visible = true,
201
192
  supabaseUrl,
202
193
  supabaseKey,
@@ -210,12 +201,14 @@ export async function setProfile({
210
201
  p_line2: line2 || null,
211
202
  p_line3: line3 || null,
212
203
  p_visible: visible,
204
+ p_matching_context: matching_context || null,
213
205
  });
214
206
  if (error) throw new Error(error.message);
215
207
 
216
- // Generate embedding for global discover matching
208
+ // Generate embedding using lines + matching_context for better quality
217
209
  try {
218
- const text = [line1, line2, line3].filter(Boolean).join(". ");
210
+ const textParts = [line1, line2, line3, matching_context].filter(Boolean);
211
+ const text = textParts.join(". ");
219
212
  if (text) {
220
213
  const embedding = await generateEmbedding(text);
221
214
  if (embedding) {
@@ -443,7 +436,7 @@ export async function discover({ device_id, supabaseUrl, supabaseKey }) {
443
436
  // Persist ref map to DB
444
437
  if (device_id && Object.keys(_refMap).length > 0) {
445
438
  try {
446
- await sb.rpc("save_scan_refs", { p_owner: device_id, p_refs: JSON.stringify(_refMap) });
439
+ await sb.rpc("save_scan_refs", { p_owner: device_id, p_refs: _refMap });
447
440
  } catch { /* best effort */ }
448
441
  }
449
442
 
@@ -520,7 +513,7 @@ export async function eventScan({ code, device_id, supabaseUrl, supabaseKey }) {
520
513
 
521
514
  // Persist refs
522
515
  if (device_id && Object.keys(_refMap).length > 0) {
523
- try { await sb.rpc("save_scan_refs", { p_owner: device_id, p_refs: JSON.stringify(_refMap) }); } catch {}
516
+ try { await sb.rpc("save_scan_refs", { p_owner: device_id, p_refs: _refMap }); } catch {}
524
517
  }
525
518
 
526
519
  return {
@@ -15,6 +15,7 @@ from .tools import (
15
15
  handle_bind,
16
16
  _sb,
17
17
  _device_id,
18
+ _my_device_ids,
18
19
  SCAN_SCHEMA,
19
20
  PROFILE_SCHEMA,
20
21
  ACCEPT_SCHEMA,
@@ -29,6 +30,11 @@ import time
29
30
  _last_event_check = 0
30
31
  _EVENT_CHECK_INTERVAL = 30 # seconds
31
32
 
33
+ # Track last match check timestamp
34
+ _last_match_check = 0
35
+ _MATCH_CHECK_INTERVAL = 60 # seconds
36
+ _notified_match_keys: set = set() # "deviceAโ†’deviceB" already notified
37
+
32
38
 
33
39
  def register(ctx):
34
40
  # โ”€โ”€ Tools โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -41,10 +47,57 @@ def register(ctx):
41
47
 
42
48
  # โ”€โ”€ Hook: auto-detect location + check web GPS events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
43
49
  def on_pre_llm(messages, **kwargs):
44
- """Check for location data in messages AND pending web GPS events."""
45
- global _last_event_check
50
+ """Check for location data in messages AND pending web GPS events AND new matches."""
51
+ global _last_event_check, _last_match_check
46
52
  hints = []
47
53
 
54
+ now = time.time()
55
+
56
+ # 0. Check for new matches (every 60s)
57
+ if now - _last_match_check > _MATCH_CHECK_INTERVAL and _my_device_ids:
58
+ _last_match_check = now
59
+ try:
60
+ sb = _sb()
61
+ for did in list(_my_device_ids):
62
+ resp = sb.rpc("get_my_matches", {"p_device_id": did}).execute()
63
+ all_matches = resp.data or []
64
+ if not all_matches:
65
+ continue
66
+
67
+ incoming = [m for m in all_matches if m.get("device_id_b") == did]
68
+ my = [m for m in all_matches if m.get("device_id_a") == did]
69
+
70
+ for m in incoming:
71
+ key = f"{m['device_id_a']}โ†’{did}"
72
+ if key in _notified_match_keys:
73
+ continue
74
+ _notified_match_keys.add(key)
75
+
76
+ # Get their profile
77
+ prof_resp = sb.rpc("get_profile", {"p_device_id": m["device_id_a"]}).execute()
78
+ p = prof_resp.data or {}
79
+ name = p.get("display_name") or "ๆœ‰ไบบ"
80
+ emoji = p.get("emoji") or "๐Ÿ‘ค"
81
+
82
+ # Check if mutual
83
+ i_accepted = any(
84
+ x.get("device_id_b") == m["device_id_a"] for x in my
85
+ )
86
+ if i_accepted:
87
+ contact = m.get("contact_info_a")
88
+ contact_msg = f"ๅฏนๆ–น็š„่”็ณปๆ–นๅผ๏ผš{contact}" if contact else "ๅฏนๆ–น่ฟ˜ๆฒกๆœ‰ๅˆ†ไบซ่”็ณปๆ–นๅผใ€‚"
89
+ hints.append(
90
+ f"[Antenna] ๐ŸŽ‰ ๅŒๅ‘ๅŒน้…๏ผ{emoji} {name} ไนŸๆŽฅๅ—ไบ†ไฝ ๏ผ{contact_msg} "
91
+ f"่ฏท็ซ‹ๅณ้€š็Ÿฅ็”จๆˆท่ฟ™ไธชๅฅฝๆถˆๆฏ๏ผ"
92
+ )
93
+ else:
94
+ hints.append(
95
+ f"[Antenna] ๐Ÿ“ฉ {emoji} {name} ๆƒณ่ฎค่ฏ†ไฝ ๏ผ"
96
+ f"่ฏทๅ‘Š่ฏ‰็”จๆˆทๆœ‰ไบบๆƒณ่ฎค่ฏ† TA๏ผŒๅนถๅปบ่ฎฎ็”จ antenna_check_matches ๆŸฅ็œ‹่ฏฆๆƒ…ใ€‚"
97
+ )
98
+ except Exception:
99
+ pass
100
+
48
101
  # 1. Check location_events table (web GPS updates)
49
102
  now = time.time()
50
103
  if now - _last_event_check > _EVENT_CHECK_INTERVAL:
@@ -29,6 +29,7 @@ _client_url = None
29
29
  _last_scan: dict[str, float] = {}
30
30
  SCAN_DEBOUNCE_S = 30
31
31
  _last_ref_map: dict[str, str] = {} # ref โ†’ device_id from last scan
32
+ _my_device_ids: set[str] = set() # track this user's device_ids for match checking
32
33
 
33
34
 
34
35
  def _get_url():
@@ -53,7 +54,9 @@ def _sb():
53
54
 
54
55
 
55
56
  def _device_id(sender_id: str, channel: str) -> str:
56
- return f"{channel}:{sender_id}"
57
+ did = f"{channel}:{sender_id}"
58
+ _my_device_ids.add(did)
59
+ return did
57
60
 
58
61
 
59
62
  def _fuzzy(lat: float, lng: float) -> tuple[float, float]:
package/lib/mcp.js CHANGED
@@ -72,16 +72,17 @@ export async function startMcpServer() {
72
72
  line1: z.string().optional(),
73
73
  line2: z.string().optional(),
74
74
  line3: z.string().optional(),
75
+ matching_context: z.string().optional().describe("Agent-generated rich context for better matching (not shown to others)"),
75
76
  visible: z.boolean().optional().default(true),
76
77
  },
77
- async ({ action, sender_id, channel, display_name, emoji, line1, line2, line3, visible }) => {
78
+ async ({ action, sender_id, channel, display_name, emoji, line1, line2, line3, matching_context, visible }) => {
78
79
  const deviceId = deriveDeviceId(sender_id, channel);
79
80
  try {
80
81
  if (action === "get") {
81
82
  const data = await getProfile({ device_id: deviceId });
82
83
  return jsonResult(data ? { profile: data } : { profile: null, message: "่ฟ˜ๆฒกๆœ‰ๅ็‰‡๏ผŒๅธฎไฝ ๅˆ›ๅปบไธ€ไธช๏ผŸ" });
83
84
  }
84
- const data = await setProfile({ device_id: deviceId, display_name, emoji, line1, line2, line3, visible });
85
+ const data = await setProfile({ device_id: deviceId, display_name, emoji, line1, line2, line3, matching_context, visible });
85
86
  return jsonResult({ saved: true, profile: data });
86
87
  } catch (e) {
87
88
  return jsonResult({ error: e.message });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antenna-fyi",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Antenna โ€” nearby people discovery. CLI + MCP server + OpenClaw skill & plugin, all in one package.",
5
5
  "type": "module",
6
6
  "bin": {
package/skill/SKILL.md CHANGED
@@ -83,7 +83,7 @@ After receiving the nearby profiles, **you decide** who to recommend:
83
83
  View or update the user's name card.
84
84
  - `action`: "get" or "set"
85
85
  - `sender_id`, `channel`: from context
86
- - For "set": `display_name`, `emoji`, `line1`, `line2`, `line3`, `visible`
86
+ - For "set": `display_name`, `emoji`, `line1`, `line2`, `line3`, `visible`, `matching_context`
87
87
 
88
88
  The name card has:
89
89
  - **emoji**: a single emoji that represents them
@@ -91,6 +91,12 @@ The name card has:
91
91
  - **line1**: who they are / what they do
92
92
  - **line2**: what they're into
93
93
  - **line3**: what they're looking for right now
94
+ - **matching_context** (optional, not shown to others): A richer description generated by the agent based on what it knows about the user โ€” career background, tech stack, interests, projects, personality traits. ~200 words. Only used for embedding-based matching, never displayed to other users.
95
+
96
+ **After saving the profile, generate `matching_context` automatically** based on your knowledge of the user (memory, conversations, context). Don't ask the user to write it โ€” you write it. Example:
97
+ > "Product designer at a tech company in Beijing, focusing on AI search experience. Interested in music (Sakamoto), swimming, cooking, language learning. Recently exploring AI agent ecosystems and social discovery. Looking to connect with AI builders, indie hackers, and creative technologists."
98
+ - **line2**: what they're into
99
+ - **line3**: what they're looking for right now
94
100
 
95
101
  ### `antenna_accept`
96
102
  Accept a match after the user sees results. Can optionally include contact info to share.