antenna-fyi 1.3.37 → 1.3.39

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
@@ -8,6 +8,7 @@ import {
8
8
  handleCheckin,
9
9
  handleMatches,
10
10
  handleDiscover,
11
+ handleFindPeople,
11
12
  handleEvent,
12
13
  handleBind,
13
14
  handlePass,
@@ -40,6 +41,9 @@ async function main() {
40
41
  return handleMatches(f);
41
42
  case "discover":
42
43
  return handleDiscover(f);
44
+ case "find":
45
+ case "find-people":
46
+ return handleFindPeople(f);
43
47
  case "event":
44
48
  return handleEvent(f);
45
49
  case "drift":
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, discover, createEvent, endEvent, eventCheckin, joinEvent, eventScan, pass as passUser, uploadEventImage, updateEvent, approveParticipant, rejectParticipant, addCohost, sendEventMessage, getMyEventMessages, getClient, verifyApiKey, linkAccount, initialRecommendations, throwDriftBottle, pickDriftBottle, replyDriftBottle, checkDriftBottles, getMyBottles } from "./core.js";
3
+ import { scan, getProfile, setProfile, accept, checkMatches, checkin, createBindToken, discover, findPeople, createEvent, endEvent, eventCheckin, joinEvent, eventScan, pass as passUser, uploadEventImage, updateEvent, approveParticipant, rejectParticipant, addCohost, sendEventMessage, getMyEventMessages, getClient, verifyApiKey, linkAccount, initialRecommendations, throwDriftBottle, pickDriftBottle, replyDriftBottle, checkDriftBottles, getMyBottles } from "./core.js";
4
4
  import { createInterface } from "readline";
5
5
  import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "fs";
6
6
  import path from "path";
@@ -14,7 +14,7 @@ const __filename = fileURLToPath(import.meta.url);
14
14
  const __dirname = dirname(__filename);
15
15
 
16
16
  export function parseFlags(args) {
17
- const flags = {};
17
+ const flags = { _: [] };
18
18
  for (let i = 0; i < args.length; i++) {
19
19
  if (args[i].startsWith("--")) {
20
20
  const key = args[i].slice(2);
@@ -25,6 +25,8 @@ export function parseFlags(args) {
25
25
  } else {
26
26
  flags[key] = true;
27
27
  }
28
+ } else {
29
+ flags._.push(args[i]);
28
30
  }
29
31
  }
30
32
  return flags;
@@ -122,7 +124,8 @@ export async function handleAccept(f) {
122
124
  if (!id || (!f.target && !f.ref)) return console.error("Usage: antenna accept --id <platform>:<user_id> --ref 1 [--contact 'WeChat: yi']\n antenna accept --id <platform>:<user_id> --target <ref_or_device_id> [--contact 'WeChat: yi']");
123
125
  const result = await accept({
124
126
  device_id: id,
125
- target_device_id: f.target || null,
127
+ target_device_id: f.target && f.target.includes(':') ? f.target : null,
128
+ profile_slug: f.target && !f.target.includes(':') ? f.target : null,
126
129
  ref: f.ref || null,
127
130
  contact_info: f.contact,
128
131
  });
@@ -178,6 +181,25 @@ export async function handleDiscover(f) {
178
181
  });
179
182
  }
180
183
 
184
+ export async function handleFindPeople(f) {
185
+ const id = resolveId(f);
186
+ const query = f.query || f.q || f._?.join(" ");
187
+ if (!id || !query) return console.error("Usage: antenna find --id <platform>:<user_id> --query '想找一个懂 consumer social 增长的人' [--limit 3]");
188
+ const result = await findPeople({ device_id: id, query, limit: +(f.limit || 3) });
189
+ if (result.count === 0) return console.log(result.message || "No relevant profiles found.");
190
+ console.log(`🔎 Intent search: ${result.query}\n`);
191
+ result.profiles.forEach((p) => {
192
+ console.log(` ${p.display_name}`);
193
+ if (p.personal_description) console.log(` ${p.personal_description}`);
194
+ if (p.looking_for) console.log(` Looking for: ${p.looking_for}`);
195
+ if (p.conversation_style) console.log(` Conversation: ${p.conversation_style}`);
196
+ if (p.interest_tags?.length) console.log(` Tags: ${p.interest_tags.join(", ")}`);
197
+ if (p.city) console.log(` ${p.city}`);
198
+ if (p.recommendation_reason) console.log(` → ${p.recommendation_reason}`);
199
+ console.log(` ref: ${p.ref}\n`);
200
+ });
201
+ }
202
+
181
203
  export async function handleEvent(f) {
182
204
  f.id = f.id || resolveId(f);
183
205
  const sub = f._?.[0] || Object.keys(f).find(k => ["create", "join", "scan", "end", "checkin", "upload-image"].includes(k));
@@ -1084,6 +1106,7 @@ Usage:
1084
1106
  antenna pass --id <platform>:<user_id> --target <ref_or_device_id> (or --ref 1)
1085
1107
  antenna matches --id <platform>:<user_id>
1086
1108
  antenna discover --id <platform>:<user_id>
1109
+ antenna find --id <platform>:<user_id> --query '想找一个懂 consumer social 增长的人' [--limit 3]
1087
1110
  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
1088
1111
  antenna drift --throw --message 'hello' | --pick | --reply --bottle-id <uuid> --message 'reply' | --check | --my-bottles --id <platform>:<user_id>
1089
1112
  antenna watch --id <platform>:<user_id> [--push hermes|openclaw|terminal] Watch for new matches in real-time (Ctrl+C to stop)
package/lib/core.js CHANGED
@@ -12,14 +12,15 @@ let _url = null;
12
12
 
13
13
  // ─── Embedding & Match Reason (via Supabase Edge Functions) ───────
14
14
 
15
- async function generateEmbedding(text) {
15
+ async function generateEmbedding(text, supabaseUrl, supabaseKey) {
16
16
  try {
17
- const sb = getClient();
17
+ getClient(supabaseUrl, supabaseKey);
18
+ const key = supabaseKey || process.env.ANTENNA_SUPABASE_KEY || process.env.ANTENNA_KEY || DEFAULT_KEY;
18
19
  const res = await fetch(`${_url || DEFAULT_URL}/functions/v1/generate-embedding`, {
19
20
  method: "POST",
20
21
  headers: {
21
22
  "Content-Type": "application/json",
22
- "Authorization": `Bearer ${process.env.ANTENNA_SUPABASE_KEY || process.env.ANTENNA_KEY || DEFAULT_KEY}`,
23
+ "Authorization": `Bearer ${key}`,
23
24
  },
24
25
  body: JSON.stringify({ text }),
25
26
  });
@@ -29,6 +30,31 @@ async function generateEmbedding(text) {
29
30
  } catch { return null; }
30
31
  }
31
32
 
33
+ function profileSearchReason(query, profile) {
34
+ if (profile.recommendation_reason) return profile.recommendation_reason;
35
+ const tags = Array.isArray(profile.interest_tags) && profile.interest_tags.length
36
+ ? ` Tags: ${profile.interest_tags.slice(0, 3).join(", ")}.`
37
+ : "";
38
+ const score = typeof profile.match_score === "number" ? ` Score: ${profile.match_score.toFixed(2)}.` : "";
39
+ return `Matches the intent "${query}".${tags}${score}`.trim();
40
+ }
41
+
42
+ function mapSearchProfile(p, ref, query) {
43
+ return {
44
+ ref: ref,
45
+ display_name: p.display_name || "匿名",
46
+ profile_slug: p.profile_slug || null,
47
+ personal_description: p.personal_description ?? p.line1 ?? null,
48
+ looking_for: p.looking_for ?? p.line2 ?? null,
49
+ conversation_style: p.conversation_style ?? p.line3 ?? null,
50
+ more_information: p.more_information ?? p.matching_context ?? null,
51
+ interest_tags: p.interest_tags || [],
52
+ city: p.city || null,
53
+ match_score: typeof p.match_score === "number" ? Math.round(p.match_score * 1000) / 1000 : null,
54
+ recommendation_reason: profileSearchReason(query, p),
55
+ };
56
+ }
57
+
32
58
  async function generateMatchReason(myLines, theirLines) {
33
59
  try {
34
60
  const res = await fetch(`${_url || DEFAULT_URL}/functions/v1/generate-match-reason`, {
@@ -119,7 +145,7 @@ export async function scan({ lat, lng, radius_m = 500, device_id, supabaseUrl, s
119
145
  const ref = String(i + 1);
120
146
  _refMap[ref] = p.device_id;
121
147
  return {
122
- ref,
148
+ ref: ref,
123
149
  name: p.display_name || "匿名",
124
150
  personal_description: p.line1,
125
151
  looking_for: p.line2,
@@ -206,7 +232,7 @@ export const PROFILE_FIELDS = {
206
232
  looking_for: { label: "想认识的人", description: "The kind of people you want to meet", maxLength: 140 },
207
233
  conversation_style: { label: "想要的交流方式", description: "The type of conversations you want", maxLength: 160 },
208
234
  more_information: { label: "更多信息", description: "Agent-generated rich context for better matching (not shown to others)", maxLength: 1000 },
209
- interest_tags: { label: "兴趣标签", description: "Interest/topic tags shown on the card (up to 8)", maxItems: 8 },
235
+ interest_tags: { label: "兴趣标签", description: "Interest/topic tags shown on the card (up to 5)", maxItems: 5 },
210
236
  city: { label: "国家/地区", description: "Country or region" },
211
237
  links: { label: "社交链接", description: "Social links shown on the card footer (up to 3)", maxItems: 3 },
212
238
  is_active: { label: "状态", description: "Whether the profile is active or quiet" },
@@ -290,7 +316,7 @@ export async function setProfile({
290
316
  if (!matching_context && !existing.context && (line1 || line2 || line3)) {
291
317
  existing.context = [line1, line2, line3].filter(Boolean).join(". ");
292
318
  }
293
- if (interest_tags) existing.interestTags = interest_tags;
319
+ if (interest_tags) existing.interestTags = interest_tags.slice(0, 5);
294
320
  if (city) existing.city = city;
295
321
  if (links) existing.links = links;
296
322
  if (is_active !== undefined) existing.isActive = is_active;
@@ -318,7 +344,7 @@ export async function setProfile({
318
344
  const textParts = [line1, line2, line3, matching_context].filter(Boolean);
319
345
  const text = textParts.join(". ");
320
346
  if (text) {
321
- const embedding = await generateEmbedding(text);
347
+ const embedding = await generateEmbedding(text, supabaseUrl, supabaseKey);
322
348
  if (embedding) {
323
349
  await sb.rpc("update_profile_embedding", {
324
350
  p_device_id: device_id,
@@ -350,45 +376,32 @@ export async function setProfile({
350
376
  }
351
377
  } catch {}
352
378
 
353
- // Generate personalized archetype description via LLM
379
+ // Generate personalized archetype via LLM (no keyword matching)
354
380
  try {
355
381
  const profileText = [line1, line2, line3, matching_context].filter(Boolean).join(". ");
356
382
  if (profileText) {
357
- // Simple keyword-based archetype detection (same logic as frontend)
358
- const corpus = profileText.toLowerCase();
359
- const archetypeKeywords = {
360
- Prometheus: ["ai", "agent", "llm", "founder", "startup", "build", "developer", "hacker", "tools", "\u667a\u80fd\u4f53", "\u521b\u4e1a", "\u5f00\u53d1"],
361
- Athena: ["product", "strategy", "research", "design", "craft", "pm", "ux", "\u4ea7\u54c1", "\u8bbe\u8ba1", "\u7814\u7a76"],
362
- Hermes: ["network", "connect", "community", "social", "bridge", "\u793e\u4ea4", "\u8fde\u63a5", "\u793e\u533a"],
363
- Apollo: ["music", "media", "content", "creator", "writing", "taste", "\u97f3\u4e50", "\u5185\u5bb9", "\u521b\u4f5c"],
364
- Artemis: ["independent", "explore", "freelance", "health", "outdoor", "\u72ec\u7acb", "\u63a2\u7d22"],
365
- Aphrodite: ["beauty", "brand", "fashion", "relationship", "\u7f8e", "\u54c1\u724c", "\u65f6\u5c1a"],
366
- Dionysus: ["event", "culture", "party", "art", "festival", "\u6d3b\u52a8", "\u6587\u5316", "\u827a\u672f"],
367
- Hades: ["finance", "invest", "infrastructure", "backend", "security", "\u6295\u8d44", "\u91d1\u878d", "\u67b6\u6784"],
368
- Persephone: ["transform", "cross", "research", "academic", "bridge", "\u8de8\u754c", "\u7814\u7a76", "\u5b66\u672f"],
369
- Odysseus: ["founder", "journey", "resilience", "travel", "startup", "\u521b\u4e1a", "\u65c5\u884c"],
370
- };
371
- let bestArchetype = "Prometheus";
372
- let bestScore = 0;
373
- for (const [role, keywords] of Object.entries(archetypeKeywords)) {
374
- const score = keywords.reduce((s, kw) => s + (corpus.includes(kw) ? 1 : 0), 0);
375
- if (score > bestScore) { bestScore = score; bestArchetype = role; }
376
- }
377
- archetypeResult = await generateArchetypeReason(bestArchetype, profileText);
378
- if (archetypeResult?.reason) {
379
- archetypeResult.archetype = bestArchetype;
380
- // Write archetype back to matching_context so dashboard can display it
381
- try {
382
- const profile = await getProfile({ device_id, supabaseUrl, supabaseKey });
383
- let ctx = {};
384
- try { ctx = JSON.parse(profile?.matching_context || "{}"); } catch {}
385
- ctx.archetypeOverride = { name: bestArchetype, reason: archetypeResult.reason, reasonZh: archetypeResult.reasonZh };
386
- await sb.rpc("upsert_profile", {
387
- p_device_id: device_id,
388
- p_matching_context: JSON.stringify(ctx),
389
- p_visible: profile?.visible ?? true,
390
- });
391
- } catch {}
383
+ const archRes = await fetch(`${_url || DEFAULT_URL}/functions/v1/generate-archetype`, {
384
+ method: "POST",
385
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${process.env.ANTENNA_SUPABASE_KEY || process.env.ANTENNA_KEY || DEFAULT_KEY}` },
386
+ body: JSON.stringify({ profile_text: profileText }),
387
+ });
388
+ if (archRes.ok) {
389
+ const archData = await archRes.json();
390
+ if (archData?.archetype && archData?.reason) {
391
+ archetypeResult = { archetype: archData.archetype, reason: archData.reason, reasonZh: archData.reasonZh };
392
+ // Write archetype back to matching_context
393
+ try {
394
+ const profile = await getProfile({ device_id, supabaseUrl, supabaseKey });
395
+ let ctx = {};
396
+ try { ctx = JSON.parse(profile?.matching_context || "{}"); } catch {}
397
+ ctx.archetypeOverride = { name: archData.archetype, reason: archData.reason, reasonZh: archData.reasonZh };
398
+ await sb.rpc("upsert_profile", {
399
+ p_device_id: device_id,
400
+ p_matching_context: JSON.stringify(ctx),
401
+ p_visible: profile?.visible ?? true,
402
+ });
403
+ } catch {}
404
+ }
392
405
  }
393
406
  }
394
407
  } catch (e) {
@@ -599,7 +612,7 @@ export async function discover({ device_id, supabaseUrl, supabaseKey }) {
599
612
  }
600
613
 
601
614
  profiles.push({
602
- ref,
615
+ ref: ref,
603
616
  name: p.display_name || "匿名",
604
617
  personal_description: p.line1,
605
618
  looking_for: p.line2,
@@ -708,6 +721,50 @@ export async function initialRecommendations({ device_id, supabaseUrl, supabaseK
708
721
  };
709
722
  }
710
723
 
724
+ // ─── findPeople (intent-based search) ───────────────────────────────
725
+
726
+ export async function findPeople({ query, device_id, limit = 3, supabaseUrl, supabaseKey }) {
727
+ const sb = getClient(supabaseUrl, supabaseKey);
728
+ const cleanQuery = String(query || "").trim();
729
+ const cappedLimit = Math.min(Math.max(Number(limit) || 3, 1), 3);
730
+
731
+ if (cleanQuery.length < 2) {
732
+ return { count: 0, profiles: [], message: "Tell me what kind of person you want to find." };
733
+ }
734
+
735
+ const embedding = await generateEmbedding(cleanQuery, supabaseUrl, supabaseKey);
736
+ const { data, error } = await sb.rpc("antenna_intent_search_people", {
737
+ p_device_id: device_id,
738
+ p_query: cleanQuery,
739
+ p_query_embedding: embedding ? `[${embedding.join(",")}]` : null,
740
+ p_limit: cappedLimit,
741
+ });
742
+
743
+ if (error) throw new Error(error.message);
744
+
745
+ const results = data || [];
746
+ const _refMap = {};
747
+ const profiles = results.map((p, i) => {
748
+ const ref = String(i + 1);
749
+ _refMap[ref] = p.device_id;
750
+ return mapSearchProfile(p, ref, cleanQuery);
751
+ });
752
+
753
+ if (device_id && Object.keys(_refMap).length > 0) {
754
+ try { await sb.rpc("save_scan_refs", { p_owner: device_id, p_refs: _refMap }); } catch {}
755
+ }
756
+
757
+ return {
758
+ count: profiles.length,
759
+ profiles,
760
+ _ref_map: _refMap,
761
+ query: cleanQuery,
762
+ message: profiles.length
763
+ ? "Intent search results. Recommend only the best fit(s), then use ref with antenna_accept if the user wants an intro."
764
+ : "No relevant active profiles found for that intent right now.",
765
+ };
766
+ }
767
+
711
768
  // ─── pass ───────────────────────────────────────────────────────────
712
769
 
713
770
  export async function pass({ device_id, target_device_id, ref, supabaseUrl, supabaseKey }) {
@@ -865,7 +922,7 @@ export async function eventScan({ code, device_id, supabaseUrl, supabaseKey }) {
865
922
  _refMap[ref] = p.device_id;
866
923
  if (p.checked_in) checkedInCount++;
867
924
  return {
868
- ref,
925
+ ref: ref,
869
926
  name: p.display_name || "匿名",
870
927
  personal_description: p.line1,
871
928
  looking_for: p.line2,
@@ -22,6 +22,7 @@ from .tools import (
22
22
  handle_bind,
23
23
  handle_pass,
24
24
  handle_discover,
25
+ handle_find_people,
25
26
  handle_event_create,
26
27
  handle_event_join,
27
28
  handle_event_scan,
@@ -47,6 +48,7 @@ from .schemas import (
47
48
  BIND_SCHEMA,
48
49
  PASS_SCHEMA,
49
50
  DISCOVER_SCHEMA,
51
+ FIND_PEOPLE_SCHEMA,
50
52
  EVENT_CREATE_SCHEMA,
51
53
  EVENT_JOIN_SCHEMA,
52
54
  EVENT_SCAN_SCHEMA,
@@ -83,6 +85,7 @@ def register(ctx):
83
85
  ctx.register_tool("antenna_bind", BIND_SCHEMA, handle_bind)
84
86
  ctx.register_tool("antenna_pass", PASS_SCHEMA, handle_pass)
85
87
  ctx.register_tool("antenna_discover", DISCOVER_SCHEMA, handle_discover)
88
+ ctx.register_tool("antenna_find_people", FIND_PEOPLE_SCHEMA, handle_find_people)
86
89
  ctx.register_tool("antenna_event_create", EVENT_CREATE_SCHEMA, handle_event_create)
87
90
  ctx.register_tool("antenna_event_join", EVENT_JOIN_SCHEMA, handle_event_join)
88
91
  ctx.register_tool("antenna_event_scan", EVENT_SCAN_SCHEMA, handle_event_scan)
@@ -3,5 +3,5 @@ version: "1.3.8"
3
3
  description: |
4
4
  Nearby people discovery + events — scan for people, set up your profile card,
5
5
  accept matches, create/join events, and get real-time notifications.
6
- 18 tools. Uses Supabase as shared backend.
6
+ 19 tools. Uses Supabase as shared backend.
7
7
  requires_env: []
@@ -420,3 +420,23 @@ INITIAL_RECOMMENDATIONS_SCHEMA = {
420
420
  "required": ["sender_id", "channel", "chat_id"],
421
421
  },
422
422
  }
423
+
424
+ FIND_PEOPLE_SCHEMA = {
425
+ "name": "antenna_find_people",
426
+ "description": (
427
+ "Find 1-3 people by a free-form intent, e.g. "
428
+ "'想找一个懂 consumer social 增长的人'. Returns privacy-safe refs; "
429
+ "use ref with antenna_accept if the user wants an intro."
430
+ ),
431
+ "parameters": {
432
+ "type": "object",
433
+ "properties": {
434
+ "query": {"type": "string", "description": "Free-form user intent describing the kind of person to find"},
435
+ "sender_id": {"type": "string", "description": "The sender's user ID"},
436
+ "channel": {"type": "string", "description": "Platform name (any platform works)"},
437
+ "chat_id": {"type": "string", "description": "REQUIRED for notifications. Pass chat/channel ID from message context."},
438
+ "limit": {"type": "number", "description": "Maximum profiles to return, 1-3"},
439
+ },
440
+ "required": ["query", "sender_id", "channel", "chat_id"],
441
+ },
442
+ }
@@ -75,6 +75,30 @@ def _ok(data) -> str:
75
75
  return json.dumps(data, ensure_ascii=False)
76
76
 
77
77
 
78
+ def _generate_embedding(text: str):
79
+ try:
80
+ req = urllib.request.Request(
81
+ f"{_get_url()}/functions/v1/generate-embedding",
82
+ data=json.dumps({"text": text}).encode(),
83
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {_get_key()}"},
84
+ )
85
+ res = urllib.request.urlopen(req, timeout=15)
86
+ body = json.loads(res.read().decode())
87
+ return body.get("embedding")
88
+ except Exception:
89
+ return None
90
+
91
+
92
+ def _search_reason(query: str, profile: dict) -> str:
93
+ if profile.get("recommendation_reason"):
94
+ return profile["recommendation_reason"]
95
+ tags = profile.get("interest_tags") or []
96
+ tag_text = f" Tags: {', '.join(tags[:3])}." if tags else ""
97
+ score = profile.get("match_score")
98
+ score_text = f" Score: {score:.2f}." if isinstance(score, (int, float)) else ""
99
+ return f'Matches the intent "{query}".{tag_text}{score_text}'.strip()
100
+
101
+
78
102
  # ─── Handlers ─────────────────────────────────────────────────────────
79
103
 
80
104
  def handle_scan(params: dict) -> str:
@@ -122,11 +146,10 @@ def handle_scan(params: dict) -> str:
122
146
  _last_ref_map[ref] = p.get("device_id")
123
147
  profiles.append({
124
148
  "ref": ref,
125
- "emoji": p.get("emoji") or "👤",
126
149
  "name": p.get("display_name") or "匿名",
127
- "line1": p.get("line1"),
128
- "line2": p.get("line2"),
129
- "line3": p.get("line3"),
150
+ "personal_description": p.get("line1"),
151
+ "looking_for": p.get("line2"),
152
+ "conversation_style": p.get("line3"),
130
153
  "more_information": p.get("matching_context") or None,
131
154
  "profile_slug": p.get("profile_slug") or None,
132
155
  "distance_m": p.get("distance_m") or p.get("dist_meters"),
@@ -154,14 +177,14 @@ def handle_profile(params: dict) -> str:
154
177
  if params["action"] == "get":
155
178
  resp = sb.rpc("get_profile", {"p_device_id": did}).execute()
156
179
  if not resp.data:
157
- return _ok({"exists": False, "message": "你还没有名片。告诉我你的名字、emoji、三句话,我帮你创建。"})
180
+ return _ok({"exists": False, "message": "你还没有名片。告诉我你是谁、做什么、想认识什么人,我帮你创建。"})
158
181
  return _ok({"exists": True, "profile": resp.data})
159
182
 
160
183
  # set
161
184
  rpc_params = {
162
185
  "p_device_id": did,
163
186
  "p_display_name": params.get("display_name"),
164
- "p_emoji": params.get("emoji"),
187
+ "p_emoji": None,
165
188
  "p_line1": params.get("line1"),
166
189
  "p_line2": params.get("line2"),
167
190
  "p_line3": params.get("line3"),
@@ -271,7 +294,6 @@ def handle_check_matches(params: dict) -> str:
271
294
  "ref": str(i + 1),
272
295
  "_device_id": m.get("target_id"),
273
296
  "name": m.get("name") or "匿名",
274
- "emoji": m.get("emoji") or "👤",
275
297
  "their_contact": m.get("their_contact"),
276
298
  "you_shared": m.get("you_shared"),
277
299
  })
@@ -282,7 +304,6 @@ def handle_check_matches(params: dict) -> str:
282
304
  "ref": str(len(mutual) + i + 1),
283
305
  "_device_id": m.get("target_id"),
284
306
  "name": m.get("name") or "匿名",
285
- "emoji": m.get("emoji") or "👤",
286
307
  "line1": m.get("line1"),
287
308
  "line2": m.get("line2"),
288
309
  "line3": m.get("line3"),
@@ -389,16 +410,14 @@ def handle_discover(params: dict) -> str:
389
410
 
390
411
  profile = {
391
412
  "ref": ref,
392
- "emoji": p.get("emoji") or "\ud83d\udc64",
393
413
  "name": p.get("display_name") or "匿名",
394
- "line1": p.get("line1"),
395
- "line2": p.get("line2"),
396
- "line3": p.get("line3"),
414
+ "personal_description": p.get("line1"),
415
+ "looking_for": p.get("line2"),
416
+ "conversation_style": p.get("line3"),
397
417
  "more_information": p.get("matching_context") or None,
398
418
  "profile_slug": p.get("profile_slug") or None,
419
+ "match_reason": match_reason,
399
420
  }
400
- if match_reason:
401
- profile["match_reason"] = match_reason
402
421
  profiles.append(profile)
403
422
 
404
423
  # Save refs and log recommendations
@@ -419,6 +438,63 @@ def handle_discover(params: dict) -> str:
419
438
  })
420
439
 
421
440
 
441
+ def handle_find_people(params: dict) -> str:
442
+ sb = _sb()
443
+ did = _device_id(params["sender_id"], params["channel"], params.get("chat_id"))
444
+ query = (params.get("query") or "").strip()
445
+ limit = max(1, min(int(params.get("limit") or 3), 3))
446
+
447
+ if len(query) < 2:
448
+ return _ok({"count": 0, "profiles": [], "message": "Tell me what kind of person you want to find."})
449
+
450
+ embedding = _generate_embedding(query)
451
+ emb_text = f"[{','.join(str(x) for x in embedding)}]" if embedding else None
452
+ resp = sb.rpc("antenna_intent_search_people", {
453
+ "p_device_id": did,
454
+ "p_query": query,
455
+ "p_query_embedding": emb_text,
456
+ "p_limit": limit,
457
+ }).execute()
458
+ results = resp.data or []
459
+
460
+ global _last_ref_map
461
+ _last_ref_map = {}
462
+ profiles = []
463
+ for i, p in enumerate(results):
464
+ ref = str(i + 1)
465
+ _last_ref_map[ref] = p.get("device_id")
466
+ profiles.append({
467
+ "ref": ref,
468
+ "display_name": p.get("display_name") or "匿名",
469
+ "profile_slug": p.get("profile_slug"),
470
+ "personal_description": p.get("personal_description"),
471
+ "looking_for": p.get("looking_for"),
472
+ "conversation_style": p.get("conversation_style"),
473
+ "more_information": p.get("more_information"),
474
+ "interest_tags": p.get("interest_tags") or [],
475
+ "city": p.get("city"),
476
+ "match_score": p.get("match_score"),
477
+ "recommendation_reason": _search_reason(query, p),
478
+ })
479
+
480
+ if did and _last_ref_map:
481
+ try:
482
+ sb.rpc("save_scan_refs", {"p_owner": did, "p_refs": _last_ref_map}).execute()
483
+ except Exception:
484
+ pass
485
+
486
+ return _ok({
487
+ "count": len(profiles),
488
+ "profiles": profiles,
489
+ "query": query,
490
+ "message": (
491
+ "Intent search results. Recommend only the best fit(s), then use ref with antenna_accept if the user wants an intro."
492
+ if profiles else
493
+ "No relevant active profiles found for that intent right now."
494
+ ),
495
+ })
496
+
497
+
422
498
  def handle_event_create(params: dict) -> str:
423
499
  sb = _sb()
424
500
  did = _device_id(params["sender_id"], params["channel"], params.get("chat_id"))
@@ -563,11 +639,10 @@ def handle_event_scan(params: dict) -> str:
563
639
  checked_in_count += 1
564
640
  profiles.append({
565
641
  "ref": ref,
566
- "emoji": p.get("emoji") or "👤",
567
642
  "name": p.get("display_name") or "匿名",
568
- "line1": p.get("line1"),
569
- "line2": p.get("line2"),
570
- "line3": p.get("line3"),
643
+ "personal_description": p.get("line1"),
644
+ "looking_for": p.get("line2"),
645
+ "conversation_style": p.get("line3"),
571
646
  "more_information": p.get("matching_context") or None,
572
647
  "profile_slug": p.get("profile_slug") or None,
573
648
  "checked_in": bool(p.get("checked_in")),
@@ -784,7 +859,6 @@ def handle_initial_recommendations(params: dict) -> str:
784
859
 
785
860
  profile = {
786
861
  "ref": ref,
787
- "emoji": p.get("emoji") or "\ud83d\udc64",
788
862
  "name": p.get("display_name") or "匿名",
789
863
  "personal_description": p.get("line1"),
790
864
  "looking_for": p.get("line2"),
package/lib/mcp.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  createBindToken,
15
15
  discover,
16
16
  initialRecommendations,
17
+ findPeople,
17
18
  createEvent,
18
19
  endEvent,
19
20
  eventCheckin,
@@ -122,7 +123,7 @@ export async function startMcpServer() {
122
123
  looking_for: z.string().optional().describe("Looking for — the kind of people you want to meet (max 140 chars)"),
123
124
  conversation_style: z.string().optional().describe("Conversation style — the type of conversations you want (max 160 chars)"),
124
125
  more_information: z.string().optional().describe("More information — agent-generated rich context for better matching (not shown to others, max 1000 chars). Generate this FIRST, then derive personal_description, looking_for, and conversation_style from it."),
125
- interest_tags: z.array(z.string()).optional().describe("Interest/topic tags shown on the card (up to 8)"),
126
+ interest_tags: z.array(z.string()).max(5).optional().describe("Interest/topic tags shown on the card (up to 5)"),
126
127
  city: z.string().optional().describe("Country or region (e.g. 'United States', 'Beijing')"),
127
128
  links: z.array(z.string()).optional().describe("Social links shown on the card footer (up to 3)"),
128
129
  is_active: z.boolean().optional().describe("Whether the profile is active or quiet"),
@@ -289,6 +290,33 @@ export async function startMcpServer() {
289
290
  }
290
291
  );
291
292
 
293
+ // ─── antenna_find_people ─────────────────────────────────────
294
+
295
+ server.tool(
296
+ "antenna_find_people",
297
+ "Find 1-3 people by a free-form intent, e.g. '找一个懂 consumer social 增长的人'. Returns privacy-safe profile refs; use ref with antenna_accept if the user wants an intro.",
298
+ {
299
+ query: z.string().describe("Free-form user intent describing the kind of person to find"),
300
+ sender_id: z.string().describe("The sender's user ID"),
301
+ channel: z.string().describe("Channel name"),
302
+ limit: z.number().optional().default(3).describe("Maximum profiles to return, 1-3"),
303
+ },
304
+ async ({ query, sender_id, channel, limit }) => {
305
+ try {
306
+ const deviceId = deriveDeviceId(sender_id, channel);
307
+ const result = await findPeople({ query, device_id: deviceId, limit });
308
+ if (result._ref_map) {
309
+ _lastRefMap = { ..._lastRefMap, ...result._ref_map };
310
+ const { _ref_map, ...clean } = result;
311
+ return jsonResult(await withMatchNotifications(deviceId, clean));
312
+ }
313
+ return jsonResult(await withMatchNotifications(deviceId, result));
314
+ } catch (e) {
315
+ return jsonResult({ error: e.message });
316
+ }
317
+ }
318
+ );
319
+
292
320
  // ─── antenna_pass ────────────────────────────────────────────
293
321
 
294
322
  server.tool(
@@ -25,7 +25,6 @@ interface Profile {
25
25
  line1: string | null;
26
26
  line2: string | null;
27
27
  line3: string | null;
28
- emoji: string | null;
29
28
  visible: boolean;
30
29
  last_seen_at?: string;
31
30
  }
@@ -33,7 +32,6 @@ interface Profile {
33
32
  interface MatchResult {
34
33
  device_id: string;
35
34
  display_name: string | null;
36
- emoji: string | null;
37
35
  line1: string | null;
38
36
  line2: string | null;
39
37
  line3: string | null;
@@ -107,6 +105,30 @@ function ok(data: any) {
107
105
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
108
106
  }
109
107
 
108
+ async function generateEmbeddingForQuery(cfg: AntennaConfig, text: string): Promise<number[] | null> {
109
+ try {
110
+ const res = await fetch(`${cfg.supabaseUrl}/functions/v1/generate-embedding`, {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${cfg.supabaseKey}` },
113
+ body: JSON.stringify({ text }),
114
+ });
115
+ if (!res.ok) return null;
116
+ const data = await res.json();
117
+ return data?.embedding || null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function intentSearchReason(query: string, profile: any): string {
124
+ if (profile.recommendation_reason) return profile.recommendation_reason;
125
+ const tags = Array.isArray(profile.interest_tags) && profile.interest_tags.length
126
+ ? ` Tags: ${profile.interest_tags.slice(0, 3).join(", ")}.`
127
+ : "";
128
+ const score = typeof profile.match_score === "number" ? ` Score: ${profile.match_score.toFixed(2)}.` : "";
129
+ return `Matches the intent "${query}".${tags}${score}`.trim();
130
+ }
131
+
110
132
  // ─── Cron helpers ────────────────────────────────────────────────────
111
133
 
112
134
  const FOLLOW_UP_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
@@ -265,15 +287,26 @@ export default function register(api: any) {
265
287
  }
266
288
 
267
289
  // Return raw profile cards — the agent decides who to recommend
268
- return ok({
269
- nearby: others.map((p: Profile) => ({
270
- device_id: p.device_id,
271
- emoji: p.emoji || "👤",
290
+ const _refMap: Record<string, string> = {};
291
+ const profiles = others.map((p: Profile, i: number) => {
292
+ const ref = String(i + 1);
293
+ _refMap[ref] = p.device_id;
294
+ return {
295
+ ref: ref,
272
296
  name: p.display_name || "匿名",
273
- line1: p.line1,
274
- line2: p.line2,
275
- line3: p.line3,
276
- })),
297
+ personal_description: p.line1,
298
+ looking_for: p.line2,
299
+ conversation_style: p.line3,
300
+ more_information: null,
301
+ profile_slug: null,
302
+ distance_m: p.distance_m ?? p.dist_meters ?? null,
303
+ };
304
+ });
305
+ (api as any)._antennaRefMap = _refMap;
306
+ try { await supabase.rpc("save_scan_refs", { p_owner: deviceId, p_refs: _refMap }); } catch {}
307
+
308
+ return ok({
309
+ profiles: profiles,
277
310
  total: others.length,
278
311
  radius_m: radius,
279
312
  instruction: "根据你对用户的了解(记忆、偏好、最近的状态),判断哪些人值得推荐,为每个推荐写一句个性化的匹配理由。",
@@ -281,13 +314,81 @@ export default function register(api: any) {
281
314
  },
282
315
  });
283
316
 
317
+ // ═══════════════════════════════════════════════════════════════════
318
+ // Tool: antenna_find_people
319
+ // ═══════════════════════════════════════════════════════════════════
320
+ api.registerTool({
321
+ name: "antenna_find_people",
322
+ description:
323
+ "Find 1-3 people by a free-form intent. Returns privacy-safe refs; use ref with antenna_accept if the user wants an intro.",
324
+ parameters: {
325
+ type: "object",
326
+ properties: {
327
+ query: { type: "string", description: "Free-form user intent describing the kind of person to find" },
328
+ sender_id: { type: "string", description: "The sender's user ID" },
329
+ channel: { type: "string", description: "The channel name" },
330
+ limit: { type: "number", description: "Maximum profiles to return, 1-3" },
331
+ },
332
+ required: ["query", "sender_id", "channel"],
333
+ },
334
+ async execute(_id: string, params: any) {
335
+ const cfg = getConfig(api);
336
+ const supabase = getSupabase(cfg);
337
+ const deviceId = deriveDeviceId(params.sender_id, params.channel);
338
+ const query = String(params.query || "").trim();
339
+ const limit = Math.min(Math.max(Number(params.limit) || 3, 1), 3);
340
+ if (query.length < 2) return ok({ count: 0, profiles: [], message: "Tell me what kind of person you want to find." });
341
+
342
+ const embedding = await generateEmbeddingForQuery(cfg, query);
343
+ const { data, error } = await supabase.rpc("antenna_intent_search_people", {
344
+ p_device_id: deviceId,
345
+ p_query: query,
346
+ p_query_embedding: embedding ? `[${embedding.join(",")}]` : null,
347
+ p_limit: limit,
348
+ });
349
+ if (error) return ok({ error: error.message });
350
+
351
+ const _refMap: Record<string, string> = {};
352
+ const profiles = (data || []).map((p: any, i: number) => {
353
+ const ref = String(i + 1);
354
+ _refMap[ref] = p.device_id;
355
+ return {
356
+ ref: ref,
357
+ display_name: p.display_name || "匿名",
358
+ profile_slug: p.profile_slug || null,
359
+ personal_description: p.personal_description || null,
360
+ looking_for: p.looking_for || null,
361
+ conversation_style: p.conversation_style || null,
362
+ more_information: p.more_information || null,
363
+ interest_tags: p.interest_tags || [],
364
+ city: p.city || null,
365
+ match_score: typeof p.match_score === "number" ? Math.round(p.match_score * 1000) / 1000 : null,
366
+ recommendation_reason: intentSearchReason(query, p),
367
+ };
368
+ });
369
+ (api as any)._antennaRefMap = { ...(api as any)._antennaRefMap, ..._refMap };
370
+ if (Object.keys(_refMap).length > 0) {
371
+ try { await supabase.rpc("save_scan_refs", { p_owner: deviceId, p_refs: _refMap }); } catch {}
372
+ }
373
+
374
+ return ok({
375
+ count: profiles.length,
376
+ profiles,
377
+ query,
378
+ message: profiles.length
379
+ ? "Intent search results. Recommend only the best fit(s), then use ref with antenna_accept if the user wants an intro."
380
+ : "No relevant active profiles found for that intent right now.",
381
+ });
382
+ },
383
+ });
384
+
284
385
  // ═══════════════════════════════════════════════════════════════════
285
386
  // Tool: antenna_profile
286
387
  // ═══════════════════════════════════════════════════════════════════
287
388
  api.registerTool({
288
389
  name: "antenna_profile",
289
390
  description:
290
- "View or update the user's Antenna profile (name card). The profile has a display name, emoji, and three lines describing who they are.",
391
+ "View or update the user's Antenna profile (name card). The profile has a display name and three lines describing who they are.",
291
392
  parameters: {
292
393
  type: "object",
293
394
  properties: {
@@ -295,7 +396,6 @@ export default function register(api: any) {
295
396
  sender_id: { type: "string", description: "The sender's user ID" },
296
397
  channel: { type: "string", description: "The channel name" },
297
398
  display_name: { type: "string", description: "Display name" },
298
- emoji: { type: "string", description: "Profile emoji" },
299
399
  line1: { type: "string", description: "First line (who you are / what you do)" },
300
400
  line2: { type: "string", description: "Second line (what you're into)" },
301
401
  line3: { type: "string", description: "Third line (what you're looking for)" },
@@ -311,18 +411,18 @@ export default function register(api: any) {
311
411
  if (params.action === "get") {
312
412
  const { data, error } = await supabase.rpc("get_profile", { p_device_id: deviceId });
313
413
  if (error || !data) {
314
- return ok({ exists: false, message: "你还没有名片。告诉我你的名字、一个 emoji、和三句话介绍自己,我帮你创建。" });
414
+ return ok({ exists: false, message: "你还没有名片。告诉我你是谁、做什么、想认识什么人,我帮你创建。" });
315
415
  }
316
416
  return ok({
317
417
  exists: true,
318
- profile: { display_name: data.display_name, emoji: data.emoji,
418
+ profile: { display_name: data.display_name,
319
419
  line1: data.line1, line2: data.line2, line3: data.line3, visible: data.visible },
320
420
  });
321
421
  }
322
422
 
323
423
  const { data, error } = await supabase.rpc("upsert_profile", {
324
424
  p_device_id: deviceId,
325
- p_display_name: params.display_name ?? null, p_emoji: params.emoji ?? null,
425
+ p_display_name: params.display_name ?? null, p_emoji: null,
326
426
  p_line1: params.line1 ?? null, p_line2: params.line2 ?? null,
327
427
  p_line3: params.line3 ?? null, p_visible: params.visible ?? true,
328
428
  });
@@ -331,7 +431,7 @@ export default function register(api: any) {
331
431
 
332
432
  return ok({
333
433
  updated: true,
334
- profile: { display_name: data.display_name, emoji: data.emoji,
434
+ profile: { display_name: data.display_name,
335
435
  line1: data.line1, line2: data.line2, line3: data.line3, visible: data.visible },
336
436
  });
337
437
  },
@@ -366,7 +466,7 @@ export default function register(api: any) {
366
466
  if (!profile) {
367
467
  return ok({
368
468
  checked_in: false,
369
- message: "你还没有名片,别人看到你也不知道你是谁。先创建一个名片吧(告诉我你的名字、emoji、三句话介绍自己)。",
469
+ message: "你还没有名片,别人看到你也不知道你是谁。先创建一个名片吧(告诉我你是谁、做什么、想认识什么人)。",
370
470
  });
371
471
  }
372
472
 
@@ -396,18 +496,28 @@ export default function register(api: any) {
396
496
  properties: {
397
497
  sender_id: { type: "string" },
398
498
  channel: { type: "string" },
499
+ ref: { type: "string", description: "Ref number from scan/find results" },
399
500
  target_device_id: { type: "string", description: "The device_id of the person to accept" },
400
501
  contact_info: { type: "string", description: "Optional contact info to share (e.g. 'WeChat: yi_xxx')" },
401
502
  },
402
- required: ["sender_id", "channel", "target_device_id"],
503
+ required: ["sender_id", "channel"],
403
504
  },
404
505
  async execute(_id: string, params: any) {
405
506
  const cfg = getConfig(api);
406
507
  const supabase = getSupabase(cfg);
407
508
  const deviceId = deriveDeviceId(params.sender_id, params.channel);
509
+ let targetId = params.target_device_id;
510
+ if (!targetId && params.ref) {
511
+ targetId = (api as any)._antennaRefMap?.[params.ref];
512
+ if (!targetId) {
513
+ const { data } = await supabase.rpc("resolve_ref", { p_owner: deviceId, p_ref: params.ref });
514
+ targetId = data;
515
+ }
516
+ }
517
+ if (!targetId) return ok({ accepted: false, error: "No target. Provide ref or target_device_id." });
408
518
 
409
519
  const { error } = await supabase.rpc("upsert_match", {
410
- p_device_id_a: deviceId, p_device_id_b: params.target_device_id,
520
+ p_device_id_a: deviceId, p_device_id_b: targetId,
411
521
  p_status: "accepted", p_contact_info: params.contact_info ?? null,
412
522
  });
413
523
 
@@ -415,13 +525,13 @@ export default function register(api: any) {
415
525
 
416
526
  const { data: myMatches } = await supabase.rpc("get_my_matches", { p_device_id: deviceId });
417
527
  const reverse = (myMatches || []).find(
418
- (m: any) => m.device_id_a === params.target_device_id && m.device_id_b === deviceId
528
+ (m: any) => m.device_id_a === targetId && m.device_id_b === deviceId
419
529
  );
420
530
 
421
531
  if (reverse) {
422
532
  // Mutual match! Stop any follow-up cron for this pair
423
- stopFollowUpCron(deviceId, params.target_device_id, logger);
424
- stopFollowUpCron(params.target_device_id, deviceId, logger);
533
+ stopFollowUpCron(deviceId, targetId, logger);
534
+ stopFollowUpCron(targetId, deviceId, logger);
425
535
 
426
536
  return ok({
427
537
  accepted: true, mutual: true,
@@ -433,11 +543,11 @@ export default function register(api: any) {
433
543
  }
434
544
 
435
545
  // Not mutual yet — start a follow-up cron (check every 15min for 2h)
436
- const { data: targetProfile } = await supabase.rpc("get_profile", { p_device_id: params.target_device_id });
546
+ const { data: targetProfile } = await supabase.rpc("get_profile", { p_device_id: targetId });
437
547
  const targetName = targetProfile?.display_name || "对方";
438
548
 
439
549
  startFollowUpCron(
440
- deviceId, params.target_device_id,
550
+ deviceId, targetId,
441
551
  params.channel, params.sender_id, targetName, logger,
442
552
  );
443
553
 
@@ -493,7 +603,7 @@ export default function register(api: any) {
493
603
  const { data: profile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_b });
494
604
  mutualMatches.push({
495
605
  device_id: match.device_id_b,
496
- name: profile?.display_name || "匿名", emoji: profile?.emoji || "👤",
606
+ name: profile?.display_name || "匿名",
497
607
  line1: profile?.line1, line2: profile?.line2, line3: profile?.line3,
498
608
  their_contact: reverse.contact_info_a || null, you_shared: match.contact_info_a || null,
499
609
  });
@@ -511,7 +621,7 @@ export default function register(api: any) {
511
621
  const { data: profile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_a });
512
622
  incomingAccepts.push({
513
623
  device_id: match.device_id_a,
514
- name: profile?.display_name || "匿名", emoji: profile?.emoji || "👤",
624
+ name: profile?.display_name || "匿名",
515
625
  line1: profile?.line1, line2: profile?.line2, line3: profile?.line3,
516
626
  });
517
627
  }
@@ -587,11 +697,10 @@ export default function register(api: any) {
587
697
  if (reverse) {
588
698
  const { data: theirProfile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_b });
589
699
  const name = theirProfile?.display_name || "对方";
590
- const emoji = theirProfile?.emoji || "👤";
591
700
  const contact = reverse.contact_info_a ? `\n对方的联系方式:${reverse.contact_info_a}` : "";
592
701
  notifyUser(
593
702
  channel, userId,
594
- `[Antenna] 🎉 双向匹配成功!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
703
+ `[Antenna] 🎉 双向匹配成功!${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
595
704
  logger,
596
705
  );
597
706
  // Clean up follow-up crons
@@ -601,14 +710,13 @@ export default function register(api: any) {
601
710
  // Someone new accepted me
602
711
  const { data: theirProfile } = await supabase.rpc("get_profile", { p_device_id: match.device_id_a });
603
712
  const name = theirProfile?.display_name || "有人";
604
- const emoji = theirProfile?.emoji || "👤";
605
713
  const iAccepted = myMatches.find((m: any) => m.device_id_b === match.device_id_a);
606
714
  if (iAccepted) {
607
715
  // I already accepted them → mutual!
608
716
  const contact = match.contact_info_a ? `\n对方的联系方式:${match.contact_info_a}` : "";
609
717
  notifyUser(
610
718
  channel, userId,
611
- `[Antenna] 🎉 双向匹配成功!${emoji} ${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
719
+ `[Antenna] 🎉 双向匹配成功!${name} 也接受了你!${contact}\n\n用 antenna_check_matches 查看详情。`,
612
720
  logger,
613
721
  );
614
722
  stopFollowUpCron(deviceId, match.device_id_a, logger);
@@ -616,7 +724,7 @@ export default function register(api: any) {
616
724
  // They accepted me but I haven't responded
617
725
  notifyUser(
618
726
  channel, userId,
619
- `[Antenna] 📩 ${emoji} ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
727
+ `[Antenna] 📩 ${name} 想认识你!看看 TA 的名片,决定要不要接受?\n\n用 antenna_check_matches 查看详情。`,
620
728
  logger,
621
729
  );
622
730
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "antenna-fyi",
3
- "version": "1.3.37",
4
- "description": "Antenna \u2014 nearby people discovery. CLI + MCP server + OpenClaw skill & plugin, all in one package.",
3
+ "version": "1.3.39",
4
+ "description": "Antenna nearby people discovery. CLI + MCP server + OpenClaw skill & plugin, all in one package.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "antenna-fyi": "bin/antenna.js",
@@ -26,4 +26,4 @@
26
26
  "@modelcontextprotocol/sdk": "^1.12.1",
27
27
  "zod": "^3.24.4"
28
28
  }
29
- }
29
+ }
package/skill/SKILL.md CHANGED
@@ -13,6 +13,7 @@ Antenna 帮你的用户发现值得认识的人。不只是"附近的人"--profi
13
13
  发现可以来自任何渠道:
14
14
  - 📡 **附近扫描** - `antenna_scan`,基于 GPS 的周边发现
15
15
  - 🌍 **全球推荐** - `antenna_discover`,每天 1 个最匹配的人
16
+ - 🔎 **意图找人** - `antenna_find_people`,用户直接说"我想找一个 xxx 的人"
16
17
  - 🔗 **Profile 链接** - 用户收到 `antenna.fyi/p/xxx` 链接,agent 读取后直接 accept
17
18
  - 🎪 **活动** - `antenna_event_scan`,同一个活动里的人
18
19
 
@@ -44,7 +45,7 @@ Plugin 安装后,agent **主动**开始引导,不要等用户问。
44
45
  跟用户聊几句,了解他们是谁、做什么、想认识什么人。然后 agent 自己生成:
45
46
  - more_information(~200 字,给 agent 匹配用的私密上下文,不展示给别人)
46
47
  - 从中提炼 personal_description/looking_for/conversation_style + display_name
47
- - 提取 interest_tags(最多 8 个,如 "AI agents", "music", "design")
48
+ - 提取 interest_tags(最多 5 个,如 "AI agents", "music", "design")
48
49
 
49
50
  展示预览给用户确认:
50
51
  > 你的名片:
@@ -103,6 +104,7 @@ openclaw cron add --every 1h --message "Check antenna matches: call antenna_chec
103
104
  - **首次安装后**:主动 onboarding
104
105
  - 用户分享位置 → `antenna_scan`
105
106
  - 用户问"附近有谁" → `antenna_scan`
107
+ - 用户说"我想找一个 xxx 的人" → `antenna_find_people`
106
108
  - 用户收到 profile 链接(`antenna.fyi/p/xxx`)→ 读取 profile → 判断 → `antenna_accept`
107
109
  - 用户想编辑名片 → `antenna_profile`
108
110
  - 用户说 accept / skip → `antenna_accept` / `antenna_pass`
@@ -119,7 +121,15 @@ openclaw cron add --every 1h --message "Check antenna matches: call antenna_chec
119
121
  每天 1 个全球最匹配的人,不需要 GPS。
120
122
  - 用在日常 cron 里,或用户主动要求
121
123
 
122
- ### 3. Profile 链接
124
+ ### 3. 意图找人(antenna_find_people)
125
+ 用户直接提出想认识的人,例如"想找一个懂 consumer social 增长的人"、"找一个做 AI hardware 的人"。
126
+ - 输入用户原话作为 `query`
127
+ - 返回 1-3 个候选 profile,包含 `ref`、`profile_slug`、三段名片、`more_information`、`interest_tags`、city 和推荐理由
128
+ - 不返回联系方式或 raw `device_id`
129
+ - 你仍然要结合上下文判断是否推荐,不要机械展示所有结果
130
+ - 用户想认识某人时,用 `ref` 调 `antenna_accept`
131
+
132
+ ### 4. Profile 链接
123
133
  用户收到 `antenna.fyi/p/xxx` 链接时:
124
134
  1. 用 `web_fetch` 读取页面--页面里有 `<script id="antenna-profile-data">` JSON,包含完整 profile
125
135
  2. 读取 more_information、interest_tags、个人描述等
@@ -128,7 +138,7 @@ openclaw cron add --every 1h --message "Check antenna matches: call antenna_chec
128
138
 
129
139
  **不需要先 scan。** Profile 链接是独立的发现路径。
130
140
 
131
- ### 4. 活动(Events)
141
+ ### 5. 活动(Events)
132
142
  同一个活动里的人。详见 EVENTS.md。
133
143
 
134
144
  ## Tools
@@ -197,6 +207,14 @@ openclaw cron add --every 1h --message "Check antenna matches: call antenna_chec
197
207
  - 不需要 GPS
198
208
  - 如果所有人都推荐过了,返回"等新人加入"
199
209
 
210
+ ### `antenna_find_people`
211
+ 意图找人--根据用户自然语言需求搜索 1-3 个相关的人。
212
+ - `query`:用户原话,如"想找一个懂 consumer social 增长的人"
213
+ - `sender_id`, `channel`, `chat_id`
214
+ - `limit`:1-3,默认 3
215
+ - 返回 `profiles`,每个 profile 有 `ref`, `display_name`, `profile_slug`, `personal_description`, `looking_for`, `conversation_style`, `more_information`, `interest_tags`, `city`, `recommendation_reason`
216
+ - 不会返回联系方式或 raw `device_id`
217
+
200
218
  ### `antenna_initial_recommendations`
201
219
  首次推荐--注册后立刻看到 2-3 个最匹配的人。
202
220
  - `sender_id`, `channel`, `chat_id`: from context