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 +4 -0
- package/lib/cli.js +26 -3
- package/lib/core.js +102 -45
- package/lib/hermes-plugin/__init__.py +3 -0
- package/lib/hermes-plugin/plugin.yaml +1 -1
- package/lib/hermes-plugin/schemas.py +20 -0
- package/lib/hermes-plugin/tools.py +93 -19
- package/lib/mcp.js +29 -1
- package/lib/plugin-template/index.ts +139 -31
- package/package.json +3 -3
- package/skill/SKILL.md +21 -3
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
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
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
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
128
|
-
"
|
|
129
|
-
"
|
|
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": "
|
|
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":
|
|
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
|
-
"
|
|
395
|
-
"
|
|
396
|
-
"
|
|
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
|
-
"
|
|
569
|
-
"
|
|
570
|
-
"
|
|
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
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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: "
|
|
414
|
+
return ok({ exists: false, message: "你还没有名片。告诉我你是谁、做什么、想认识什么人,我帮你创建。" });
|
|
315
415
|
}
|
|
316
416
|
return ok({
|
|
317
417
|
exists: true,
|
|
318
|
-
profile: { display_name: data.display_name,
|
|
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:
|
|
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,
|
|
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: "
|
|
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"
|
|
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:
|
|
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 ===
|
|
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,
|
|
424
|
-
stopFollowUpCron(
|
|
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:
|
|
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,
|
|
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 || "匿名",
|
|
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 || "匿名",
|
|
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] 🎉 双向匹配成功!${
|
|
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] 🎉 双向匹配成功!${
|
|
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] 📩 ${
|
|
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.
|
|
4
|
-
"description": "Antenna
|
|
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(最多
|
|
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.
|
|
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
|
-
###
|
|
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
|