antenna-fyi 1.3.39 → 1.3.41
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 +9 -3
- package/lib/cli.js +8 -1
- package/lib/core.js +24 -12
- package/lib/hermes-plugin/tools.py +31 -1
- package/lib/mcp.js +1 -1
- package/lib/plugin-template/index.ts +26 -0
- package/package.json +1 -1
- package/skill/SKILL.md +9 -1
package/README.md
CHANGED
|
@@ -11,6 +11,10 @@ npm install -g antenna-fyi
|
|
|
11
11
|
## CLI Usage
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
+
# Find people by intent (agent-facing search)
|
|
15
|
+
antenna find --id <platform>:<user_id> --query "想找一个懂 consumer social 增长的人"
|
|
16
|
+
antenna find-people --id <platform>:<user_id> --query "find someone building AI hardware" --limit 3
|
|
17
|
+
|
|
14
18
|
# Create your profile card
|
|
15
19
|
antenna setup --id <platform>:<user_id>
|
|
16
20
|
|
|
@@ -48,6 +52,7 @@ This starts a stdio-based MCP server with tools:
|
|
|
48
52
|
- `antenna_checkin` — Check in at a location
|
|
49
53
|
- `antenna_accept` — Accept a match
|
|
50
54
|
- `antenna_check_matches` — Check match status
|
|
55
|
+
- `antenna_find_people` — Find 1-3 people from a free-form user intent. Use when the user says "I want to meet/find someone who..."; returns refs and safe profile fields, not contact info or raw device IDs.
|
|
51
56
|
|
|
52
57
|
## Hermes Agent Integration
|
|
53
58
|
|
|
@@ -103,9 +108,10 @@ The plugin adds automatic location-triggered scanning, match polling, and real-t
|
|
|
103
108
|
## How It Works
|
|
104
109
|
|
|
105
110
|
1. **Create a profile card** — emoji, name, 3 lines about you
|
|
106
|
-
2. **
|
|
107
|
-
3. **
|
|
108
|
-
4. **
|
|
111
|
+
2. **Find by intent** — users can say "I want someone who understands X"; agents call `antenna_find_people` / `antenna find`
|
|
112
|
+
3. **Scan nearby** — find people within radius at your location
|
|
113
|
+
4. **Accept matches** — if both sides accept, exchange contact info
|
|
114
|
+
5. **Everything expires in 24h** — ephemeral by design
|
|
109
115
|
|
|
110
116
|
## License
|
|
111
117
|
|
package/lib/cli.js
CHANGED
|
@@ -1098,6 +1098,12 @@ export async function handleDrift(f) {
|
|
|
1098
1098
|
export function printHelp() {
|
|
1099
1099
|
console.log(`📡 Antenna — nearby people discovery
|
|
1100
1100
|
|
|
1101
|
+
Agent shortcuts:
|
|
1102
|
+
antenna find Find 1-3 people by intent, e.g. "想找一个懂 consumer social 增长的人"
|
|
1103
|
+
antenna discover Get today's global recommendation
|
|
1104
|
+
antenna scan Find nearby people after GPS is available
|
|
1105
|
+
antenna accept Use a returned ref/profile_slug when the user wants an intro
|
|
1106
|
+
|
|
1101
1107
|
Usage:
|
|
1102
1108
|
antenna scan --lat 39.99 --lng 116.48 [--radius 500] (max 1000) [--id <platform>:<user_id>]
|
|
1103
1109
|
antenna checkin --id <platform>:<user_id> --lat 39.99 --lng 116.48
|
|
@@ -1107,6 +1113,7 @@ Usage:
|
|
|
1107
1113
|
antenna matches --id <platform>:<user_id>
|
|
1108
1114
|
antenna discover --id <platform>:<user_id>
|
|
1109
1115
|
antenna find --id <platform>:<user_id> --query '想找一个懂 consumer social 增长的人' [--limit 3]
|
|
1116
|
+
antenna find-people --id <platform>:<user_id> --query 'find someone building AI hardware' [--limit 3]
|
|
1110
1117
|
antenna event --create --name 'AI Meetup' --starts-at '...' --ends-at '...' [--lat 34.05 --lng -118.25] [--desc '...'] [--og-image 'url'] [--requires-approval] [--screening-questions 'Q1|Q2'] | --join --code abc123 | --scan --code abc123 | --end --code abc123 --id <platform>:<user_id> | --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
|
|
1111
1118
|
antenna drift --throw --message 'hello' | --pick | --reply --bottle-id <uuid> --message 'reply' | --check | --my-bottles --id <platform>:<user_id>
|
|
1112
1119
|
antenna watch --id <platform>:<user_id> [--push hermes|openclaw|terminal] Watch for new matches in real-time (Ctrl+C to stop)
|
|
@@ -1126,5 +1133,5 @@ Environment:
|
|
|
1126
1133
|
ANTENNA_SUPABASE_KEY Supabase anon key (optional, has default)
|
|
1127
1134
|
|
|
1128
1135
|
Install: npm install -g antenna-fyi
|
|
1129
|
-
Or: npx antenna-fyi
|
|
1136
|
+
Or: npx antenna-fyi find --id <platform>:<user_id> --query 'AI hardware builders'`);
|
|
1130
1137
|
}
|
package/lib/core.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// All three import this instead of duplicating Supabase calls.
|
|
3
3
|
|
|
4
4
|
import { createClient } from "@supabase/supabase-js";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
5
6
|
|
|
6
7
|
const DEFAULT_URL = "https://bcudjloikmpcqwcptuyd.supabase.co";
|
|
7
8
|
const DEFAULT_KEY =
|
|
@@ -55,6 +56,17 @@ function mapSearchProfile(p, ref, query) {
|
|
|
55
56
|
};
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
function profileSlugCandidate(displayName, deviceId) {
|
|
60
|
+
const fromName = String(displayName || "")
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
63
|
+
.replace(/^-|-$/g, "")
|
|
64
|
+
.substring(0, 30);
|
|
65
|
+
if (fromName) return fromName;
|
|
66
|
+
const hash = createHash("sha1").update(String(deviceId || "antenna-user")).digest("hex").slice(0, 10);
|
|
67
|
+
return `user-${hash}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
58
70
|
async function generateMatchReason(myLines, theirLines) {
|
|
59
71
|
try {
|
|
60
72
|
const res = await fetch(`${_url || DEFAULT_URL}/functions/v1/generate-match-reason`, {
|
|
@@ -227,15 +239,15 @@ export async function scan({ lat, lng, radius_m = 500, device_id, supabaseUrl, s
|
|
|
227
239
|
// ─── Profile field metadata (self-describing API) ───────────────────
|
|
228
240
|
|
|
229
241
|
export const PROFILE_FIELDS = {
|
|
230
|
-
display_name: { label: "显示名称", description: "How you want to be called" },
|
|
231
|
-
personal_description: { label: "个人描述", description: "Who you are and what you do", maxLength: 220, required: true },
|
|
232
|
-
looking_for: { label: "想认识的人", description: "The kind of people you want to meet", maxLength: 140 },
|
|
233
|
-
conversation_style: { label: "想要的交流方式", description: "The type of conversations you want", maxLength: 160 },
|
|
234
|
-
more_information: { label: "更多信息", description: "Agent-generated rich context for better matching (not shown to others)", maxLength: 1000 },
|
|
235
|
-
interest_tags: { label: "兴趣标签", description: "Interest/topic tags shown on the card (up to 5)", maxItems: 5 },
|
|
236
|
-
city: { label: "国家/地区", description: "Country or region" },
|
|
237
|
-
links: { label: "社交链接", description: "Social links shown on the card footer (up to 3)", maxItems: 3 },
|
|
238
|
-
is_active: { label: "状态", description: "Whether the profile is active or quiet" },
|
|
242
|
+
display_name: { label: "显示名称", i18n: { en: "Display name", zh: "显示名称" }, description: "How you want to be called" },
|
|
243
|
+
personal_description: { label: "个人描述", i18n: { en: "Personal description", zh: "个人描述" }, description: "Who you are and what you do", maxLength: 220, required: true },
|
|
244
|
+
looking_for: { label: "想认识的人", i18n: { en: "Looking for", zh: "想认识的人" }, description: "The kind of people you want to meet", maxLength: 140 },
|
|
245
|
+
conversation_style: { label: "想要的交流方式", i18n: { en: "Conversation style", zh: "想要的交流方式" }, description: "The type of conversations you want", maxLength: 160 },
|
|
246
|
+
more_information: { label: "更多信息", i18n: { en: "More information", zh: "更多信息" }, description: "Agent-generated rich context for better matching (not shown to others)", maxLength: 1000 },
|
|
247
|
+
interest_tags: { label: "兴趣标签", i18n: { en: "Interest tags", zh: "兴趣标签" }, description: "Interest/topic tags shown on the card (up to 5)", maxItems: 5 },
|
|
248
|
+
city: { label: "国家/地区", i18n: { en: "Country/region", zh: "国家/地区" }, description: "Country or region" },
|
|
249
|
+
links: { label: "社交链接", i18n: { en: "Social links", zh: "社交链接" }, description: "Social links shown on the card footer (up to 3)", maxItems: 3 },
|
|
250
|
+
is_active: { label: "状态", i18n: { en: "Status", zh: "状态" }, description: "Whether the profile is active or quiet" },
|
|
239
251
|
};
|
|
240
252
|
|
|
241
253
|
// ─── getProfile ──────────────────────────────────────────────────────
|
|
@@ -335,7 +347,7 @@ export async function setProfile({
|
|
|
335
347
|
p_visible: visible,
|
|
336
348
|
p_matching_context: contextJson || null,
|
|
337
349
|
p_contact_info: contact_info || null,
|
|
338
|
-
|
|
350
|
+
p_api_key: api_key || null,
|
|
339
351
|
});
|
|
340
352
|
if (error) throw new Error(error.message);
|
|
341
353
|
|
|
@@ -366,9 +378,9 @@ export async function setProfile({
|
|
|
366
378
|
const profile = await getProfile({ device_id, supabaseUrl, supabaseKey });
|
|
367
379
|
profileSlug = profile?.profile_slug || null;
|
|
368
380
|
// Use explicitly passed slug, or auto-generate from display_name
|
|
369
|
-
const targetSlug = profile_slug || (!profileSlug
|
|
381
|
+
const targetSlug = profile_slug || (!profileSlug ? profileSlugCandidate(display_name, device_id) : null);
|
|
370
382
|
if (targetSlug && targetSlug !== profileSlug) {
|
|
371
|
-
const { data: slugResult } = await sb.rpc("set_profile_slug", { p_device_id: device_id, p_slug: targetSlug });
|
|
383
|
+
const { data: slugResult } = await sb.rpc("set_profile_slug", { p_device_id: device_id, p_slug: targetSlug, p_api_key: api_key || null });
|
|
372
384
|
if (slugResult?.set) profileSlug = targetSlug;
|
|
373
385
|
}
|
|
374
386
|
if (profileSlug) {
|
|
@@ -5,6 +5,7 @@ shared backend if no env vars are set.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
|
+
import hashlib
|
|
8
9
|
import math
|
|
9
10
|
import os
|
|
10
11
|
import time
|
|
@@ -33,6 +34,15 @@ _last_ref_map: dict[str, str] = {} # ref → device_id from last scan
|
|
|
33
34
|
_my_device_ids: set[str] = set() # track this user's device_ids for match checking
|
|
34
35
|
|
|
35
36
|
|
|
37
|
+
def _profile_slug_candidate(display_name: str | None, device_id: str) -> str:
|
|
38
|
+
raw = "".join(ch.lower() if ch.isalnum() and ch.isascii() else "-" for ch in (display_name or ""))
|
|
39
|
+
slug = "-".join(part for part in raw.split("-") if part)[:30].strip("-")
|
|
40
|
+
if slug:
|
|
41
|
+
return slug
|
|
42
|
+
digest = hashlib.sha1(device_id.encode("utf-8")).hexdigest()[:10]
|
|
43
|
+
return f"user-{digest}"
|
|
44
|
+
|
|
45
|
+
|
|
36
46
|
def _get_url():
|
|
37
47
|
return os.environ.get("ANTENNA_SUPABASE_URL") or os.environ.get("ANTENNA_URL") or BUILTIN_URL
|
|
38
48
|
|
|
@@ -192,10 +202,30 @@ def handle_profile(params: dict) -> str:
|
|
|
192
202
|
}
|
|
193
203
|
if params.get("matching_context") is not None:
|
|
194
204
|
rpc_params["p_matching_context"] = params["matching_context"]
|
|
205
|
+
rpc_params["p_api_key"] = params.get("api_key")
|
|
195
206
|
resp = sb.rpc("upsert_profile", rpc_params).execute()
|
|
196
207
|
|
|
197
208
|
if resp.data:
|
|
198
|
-
|
|
209
|
+
public_url = None
|
|
210
|
+
try:
|
|
211
|
+
profile_resp = sb.rpc("get_profile", {"p_device_id": did}).execute()
|
|
212
|
+
profile = profile_resp.data or {}
|
|
213
|
+
profile_slug = profile.get("profile_slug")
|
|
214
|
+
if not profile_slug:
|
|
215
|
+
target_slug = _profile_slug_candidate(params.get("display_name"), did)
|
|
216
|
+
slug_resp = sb.rpc("set_profile_slug", {"p_device_id": did, "p_slug": target_slug, "p_api_key": params.get("api_key")}).execute()
|
|
217
|
+
if isinstance(slug_resp.data, dict) and slug_resp.data.get("set"):
|
|
218
|
+
profile_slug = target_slug
|
|
219
|
+
if profile_slug:
|
|
220
|
+
public_url = f"https://www.antenna.fyi/p/{profile_slug}"
|
|
221
|
+
except Exception:
|
|
222
|
+
public_url = None
|
|
223
|
+
return _ok({
|
|
224
|
+
"updated": True,
|
|
225
|
+
"profile": resp.data,
|
|
226
|
+
"public_url": public_url,
|
|
227
|
+
"next_step": "IMPORTANT: 1) Send public_url to the user — this is their shareable profile link. 2) Call antenna_bind to generate a GPS link. Do not skip either step.",
|
|
228
|
+
})
|
|
199
229
|
return _ok({"error": "upsert_profile failed"})
|
|
200
230
|
|
|
201
231
|
|
package/lib/mcp.js
CHANGED
|
@@ -113,7 +113,7 @@ export async function startMcpServer() {
|
|
|
113
113
|
|
|
114
114
|
server.tool(
|
|
115
115
|
"antenna_profile",
|
|
116
|
-
"Get or set the user's Antenna profile card.
|
|
116
|
+
"Get or set the user's Antenna profile card. After setting a profile, send profile.public_url to the user as their public profile link, then send the GPS bind link.",
|
|
117
117
|
{
|
|
118
118
|
action: z.enum(["get", "set"]).describe("'get' to read, 'set' to write"),
|
|
119
119
|
sender_id: z.string().describe("The sender's user ID"),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createClient, SupabaseClient } from "@supabase/supabase-js";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
|
+
import { createHash } from "crypto";
|
|
3
4
|
|
|
4
5
|
// ─── Built-in Supabase config (shared backend, zero config) ─────────
|
|
5
6
|
|
|
@@ -66,6 +67,16 @@ function getSupabase(cfg: AntennaConfig): SupabaseClient {
|
|
|
66
67
|
return _supabaseClient;
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
function profileSlugCandidate(displayName: string | null | undefined, deviceId: string) {
|
|
71
|
+
const fromName = String(displayName || "")
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
74
|
+
.replace(/^-|-$/g, "")
|
|
75
|
+
.substring(0, 30);
|
|
76
|
+
if (fromName) return fromName;
|
|
77
|
+
return `user-${createHash("sha1").update(deviceId).digest("hex").slice(0, 10)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
69
80
|
function isRateLimited(deviceId: string): boolean {
|
|
70
81
|
const now = Date.now();
|
|
71
82
|
const last = _lastScanTime.get(deviceId);
|
|
@@ -425,14 +436,29 @@ export default function register(api: any) {
|
|
|
425
436
|
p_display_name: params.display_name ?? null, p_emoji: null,
|
|
426
437
|
p_line1: params.line1 ?? null, p_line2: params.line2 ?? null,
|
|
427
438
|
p_line3: params.line3 ?? null, p_visible: params.visible ?? true,
|
|
439
|
+
p_api_key: null,
|
|
428
440
|
});
|
|
429
441
|
|
|
430
442
|
if (error) return ok({ error: error.message });
|
|
431
443
|
|
|
444
|
+
let publicUrl = null;
|
|
445
|
+
try {
|
|
446
|
+
const { data: profile } = await supabase.rpc("get_profile", { p_device_id: deviceId });
|
|
447
|
+
let profileSlug = profile?.profile_slug || null;
|
|
448
|
+
if (!profileSlug) {
|
|
449
|
+
const targetSlug = profileSlugCandidate(params.display_name, deviceId);
|
|
450
|
+
const { data: slugResult } = await supabase.rpc("set_profile_slug", { p_device_id: deviceId, p_slug: targetSlug, p_api_key: null });
|
|
451
|
+
if (slugResult?.set) profileSlug = targetSlug;
|
|
452
|
+
}
|
|
453
|
+
if (profileSlug) publicUrl = `https://www.antenna.fyi/p/${profileSlug}`;
|
|
454
|
+
} catch {}
|
|
455
|
+
|
|
432
456
|
return ok({
|
|
433
457
|
updated: true,
|
|
434
458
|
profile: { display_name: data.display_name,
|
|
435
459
|
line1: data.line1, line2: data.line2, line3: data.line3, visible: data.visible },
|
|
460
|
+
public_url: publicUrl,
|
|
461
|
+
next_step: "IMPORTANT: Send the public_url to the user — this is their shareable profile link. Then call antenna_bind to generate a GPS link. Do not skip either step.",
|
|
436
462
|
});
|
|
437
463
|
},
|
|
438
464
|
});
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -23,6 +23,8 @@ Antenna 帮你的用户发现值得认识的人。不只是"附近的人"--profi
|
|
|
23
23
|
|
|
24
24
|
- 如果有 Antenna plugin tools(antenna_scan, antenna_profile 等),直接用。
|
|
25
25
|
- 如果没有,用 CLI:`antenna <command> [flags]`。
|
|
26
|
+
- 用户说"我想找一个 xxx 的人"、"find someone who..."、"有没有做 X 的人"时,优先用 `antenna_find_people`。CLI 等价命令是:
|
|
27
|
+
`antenna find --id <platform>:<user_id> --query "<用户原话>" --limit 3`
|
|
26
28
|
- **所有 tool 必须传 `chat_id`**--Antenna 用它发通知。
|
|
27
29
|
|
|
28
30
|
## Onboarding - 装上后主动引导
|
|
@@ -69,10 +71,12 @@ Plugin 安装后,agent **主动**开始引导,不要等用户问。
|
|
|
69
71
|
|
|
70
72
|
**第四步:发公开链接给用户**
|
|
71
73
|
|
|
72
|
-
Profile 存好后,返回里会有 `public_url`(如 `antenna.fyi/p/yi`)
|
|
74
|
+
Profile 存好后,返回里会有 `public_url`(如 `antenna.fyi/p/yi`)。**必须立刻发给用户。不要只说"保存好了"。**
|
|
73
75
|
> "这是你的公开名片链接:[public_url]
|
|
74
76
|
> 你可以把它发给别人,对方的 agent 看到链接就能直接 accept 你。"
|
|
75
77
|
|
|
78
|
+
如果 `public_url` 为空,说明 profile link 生成失败。立刻重试保存 profile 或提示错误,不要进入下一步。
|
|
79
|
+
|
|
76
80
|
**第五步:调 antenna_bind 获取 GPS 链接**
|
|
77
81
|
|
|
78
82
|
**强制。** 名片存好后**必须立刻**调用 `antenna_bind` 生成链接并发给用户:
|
|
@@ -128,6 +132,8 @@ openclaw cron add --every 1h --message "Check antenna matches: call antenna_chec
|
|
|
128
132
|
- 不返回联系方式或 raw `device_id`
|
|
129
133
|
- 你仍然要结合上下文判断是否推荐,不要机械展示所有结果
|
|
130
134
|
- 用户想认识某人时,用 `ref` 调 `antenna_accept`
|
|
135
|
+
- 没有 plugin tool 时,用 CLI:
|
|
136
|
+
`antenna find --id <platform>:<user_id> --query "想找一个懂 consumer social 增长的人" --limit 3`
|
|
131
137
|
|
|
132
138
|
### 4. Profile 链接
|
|
133
139
|
用户收到 `antenna.fyi/p/xxx` 链接时:
|
|
@@ -166,6 +172,8 @@ openclaw cron add --every 1h --message "Check antenna matches: call antenna_chec
|
|
|
166
172
|
- **conversation_style**:想要的交流方式
|
|
167
173
|
- **matching_context**(more_information,不展示给别人):agent 基于对用户的了解生成的详细描述,~200 字。**这是匹配的核心数据源。** personal_description/looking_for/conversation_style 从它提炼出来,不是反过来。
|
|
168
174
|
|
|
175
|
+
**i18n:** 用户填写的内容按原文保存和展示,不要自动翻译用户的 profile 文本。Antenna 的网页 UI 会切换中文/英文标签; profile 内容本身保持用户选择的语言。保存后返回的 `public_url` 必须发给用户。
|
|
176
|
+
|
|
169
177
|
### `antenna_accept`
|
|
170
178
|
接受一个匹配。**不需要先 scan**--任何发现路径都可以触发 accept。
|
|
171
179
|
- `sender_id`, `channel`, `chat_id`:必填
|