slashvibe-mcp 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/analytics.js +107 -0
- package/config.js +174 -3
- package/index.js +163 -34
- package/intelligence/index.js +45 -0
- package/intelligence/infer.js +316 -0
- package/intelligence/interests.js +369 -0
- package/intelligence/patterns.js +651 -0
- package/intelligence/proactive.js +358 -0
- package/intelligence/serendipity.js +306 -0
- package/notification-emitter.js +77 -0
- package/notify.js +141 -18
- package/package.json +14 -6
- package/presence.js +5 -1
- package/protocol/index.js +88 -1
- package/protocol/telegram-commands.js +199 -0
- package/store/api.js +469 -29
- package/store/index.js +7 -7
- package/store/local.js +67 -11
- package/store/profiles.js +435 -0
- package/store/reservations.js +321 -0
- package/store/skills.js +378 -0
- package/tools/_actions.js +491 -22
- package/tools/_connection-queue.js +257 -0
- package/tools/_discovery-enhanced.js +290 -0
- package/tools/_discovery.js +439 -0
- package/tools/_proactive-discovery.js +301 -0
- package/tools/_shared/index.js +64 -0
- package/tools/_work-context.js +338 -0
- package/tools/_work-context.manual-test.js +199 -0
- package/tools/_work-context.test.js +260 -0
- package/tools/admin-inbox.js +218 -0
- package/tools/agent-treasury.js +288 -0
- package/tools/agents.js +122 -0
- package/tools/analytics.js +191 -0
- package/tools/approve.js +197 -0
- package/tools/arcade.js +173 -0
- package/tools/artifact-create.js +247 -0
- package/tools/artifact-view.js +174 -0
- package/tools/artifacts-price.js +107 -0
- package/tools/ask-expert.js +160 -0
- package/tools/auto-suggest-connections.js +304 -0
- package/tools/away.js +68 -0
- package/tools/back.js +51 -0
- package/tools/become-expert.js +150 -0
- package/tools/bootstrap-skills.js +231 -0
- package/tools/bridge-dashboard.js +342 -0
- package/tools/bridge-health.js +400 -0
- package/tools/bridge-live.js +384 -0
- package/tools/bridges.js +383 -0
- package/tools/broadcast.js +286 -0
- package/tools/bye.js +4 -0
- package/tools/chat.js +202 -0
- package/tools/collaborative-drawing.js +286 -0
- package/tools/colorguess.js +281 -0
- package/tools/crossword.js +369 -0
- package/tools/discover-insights.js +379 -0
- package/tools/discover-momentum.js +256 -0
- package/tools/discover.js +675 -0
- package/tools/discovery-analytics.js +345 -0
- package/tools/discovery-auto-suggest.js +275 -0
- package/tools/discovery-bootstrap.js +267 -0
- package/tools/discovery-daily.js +375 -0
- package/tools/discovery-dashboard.js +385 -0
- package/tools/discovery-digest.js +314 -0
- package/tools/discovery-hub.js +357 -0
- package/tools/discovery-insights.js +384 -0
- package/tools/discovery-momentum.js +281 -0
- package/tools/discovery-monitor.js +319 -0
- package/tools/discovery-proactive.js +300 -0
- package/tools/dm.js +84 -14
- package/tools/draw.js +317 -0
- package/tools/drawing.js +310 -0
- package/tools/earnings.js +126 -0
- package/tools/echo.js +16 -0
- package/tools/farcaster.js +307 -0
- package/tools/feed.js +215 -0
- package/tools/follow.js +224 -0
- package/tools/friends.js +192 -0
- package/tools/game.js +218 -110
- package/tools/games-catalog.js +376 -0
- package/tools/games.js +313 -0
- package/tools/genesis.js +233 -0
- package/tools/gig-browse.js +206 -0
- package/tools/gig-complete.js +139 -0
- package/tools/guessnumber.js +194 -0
- package/tools/hangman.js +129 -0
- package/tools/help.js +269 -0
- package/tools/idea.js +217 -0
- package/tools/inbox.js +291 -25
- package/tools/init.js +657 -33
- package/tools/insights.js +123 -0
- package/tools/invite.js +142 -21
- package/tools/l2-bridge.js +272 -0
- package/tools/l2-status.js +217 -0
- package/tools/l2.js +206 -0
- package/tools/migrate.js +156 -0
- package/tools/mint.js +377 -0
- package/tools/multiplayer-game.js +275 -0
- package/tools/multiplayer-tictactoe.js +303 -0
- package/tools/mute.js +97 -0
- package/tools/notifications.js +415 -0
- package/tools/observe.js +200 -0
- package/tools/onboarding.js +147 -0
- package/tools/open.js +52 -3
- package/tools/party-game.js +314 -0
- package/tools/plan.js +225 -0
- package/tools/presence-agent.js +167 -0
- package/tools/profile.js +219 -0
- package/tools/proof-of-work.js +139 -0
- package/tools/pulse.js +218 -0
- package/tools/react.js +4 -0
- package/tools/release.js +83 -0
- package/tools/report.js +109 -0
- package/tools/reputation.js +175 -0
- package/tools/request.js +231 -0
- package/tools/reservations.js +116 -0
- package/tools/reserve.js +111 -0
- package/tools/riddle.js +240 -0
- package/tools/run-bootstrap.js +69 -0
- package/tools/schedule.js +367 -0
- package/tools/session.js +420 -0
- package/tools/session_price.js +128 -0
- package/tools/settings.js +200 -0
- package/tools/ship.js +188 -0
- package/tools/shipback.js +326 -0
- package/tools/skills-analytics.js +349 -0
- package/tools/skills-bootstrap.js +301 -0
- package/tools/skills-dashboard.js +268 -0
- package/tools/skills-exchange.js +342 -0
- package/tools/skills.js +380 -0
- package/tools/smart-intro.js +353 -0
- package/tools/social-inbox.js +326 -69
- package/tools/social-post.js +251 -66
- package/tools/social-processor.js +445 -0
- package/tools/solo-game.js +390 -0
- package/tools/start.js +296 -81
- package/tools/status.js +53 -6
- package/tools/storybuilder.js +331 -0
- package/tools/stuck.js +297 -0
- package/tools/subscribe.js +148 -0
- package/tools/subscriptions.js +134 -0
- package/tools/suggest-tags.js +184 -0
- package/tools/tag-suggestions.js +257 -0
- package/tools/telegram-bot.js +183 -0
- package/tools/telegram-setup.js +214 -0
- package/tools/tictactoe.js +155 -0
- package/tools/tip.js +120 -0
- package/tools/token.js +103 -0
- package/tools/twentyquestions.js +143 -0
- package/tools/update.js +1 -1
- package/tools/wallet.js +127 -0
- package/tools/watch.js +157 -0
- package/tools/webhook-test.js +388 -0
- package/tools/who.js +118 -25
- package/tools/withdraw.js +145 -0
- package/tools/wordassociation.js +247 -0
- package/tools/work-summary.js +96 -0
- package/tools/workshop-buddy.js +394 -0
- package/tools/workshop.js +327 -0
- package/version.json +12 -3
- package/tools/board.js +0 -130
package/store/local.js
CHANGED
|
@@ -8,6 +8,7 @@ const config = require('../config');
|
|
|
8
8
|
|
|
9
9
|
const MESSAGES_FILE = path.join(config.VIBE_DIR, 'messages.jsonl');
|
|
10
10
|
const PRESENCE_FILE = path.join(config.VIBE_DIR, 'presence.json');
|
|
11
|
+
const SKILL_EXCHANGE_FILE = path.join(config.VIBE_DIR, 'skill-exchanges.jsonl');
|
|
11
12
|
|
|
12
13
|
// ============ SESSION (stubs for local mode) ============
|
|
13
14
|
|
|
@@ -41,7 +42,7 @@ function savePresence(presence) {
|
|
|
41
42
|
fs.writeFileSync(PRESENCE_FILE, JSON.stringify(presence, null, 2));
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
function heartbeat(handle, one_liner) {
|
|
45
|
+
async function heartbeat(handle, one_liner) {
|
|
45
46
|
const presence = loadPresence();
|
|
46
47
|
presence[handle] = {
|
|
47
48
|
handle,
|
|
@@ -52,7 +53,7 @@ function heartbeat(handle, one_liner) {
|
|
|
52
53
|
savePresence(presence);
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
function getActiveUsers() {
|
|
56
|
+
async function getActiveUsers() {
|
|
56
57
|
const presence = loadPresence();
|
|
57
58
|
const now = Date.now();
|
|
58
59
|
const IDLE_THRESHOLD = 5 * 60 * 1000; // 5 minutes
|
|
@@ -76,7 +77,7 @@ function getActiveUsers() {
|
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
function setVisibility(handle, visible) {
|
|
80
|
+
async function setVisibility(handle, visible) {
|
|
80
81
|
const presence = loadPresence();
|
|
81
82
|
if (presence[handle]) {
|
|
82
83
|
presence[handle].visible = visible;
|
|
@@ -102,7 +103,7 @@ function appendMessage(msg) {
|
|
|
102
103
|
fs.appendFileSync(MESSAGES_FILE, JSON.stringify(msg) + '\n');
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
function sendMessage(from, to, body, type = 'dm') {
|
|
106
|
+
async function sendMessage(from, to, body, type = 'dm') {
|
|
106
107
|
const msg = {
|
|
107
108
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
108
109
|
from: from.toLowerCase().replace('@', ''),
|
|
@@ -116,7 +117,7 @@ function sendMessage(from, to, body, type = 'dm') {
|
|
|
116
117
|
return msg;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
|
-
function getInbox(handle) {
|
|
120
|
+
async function getInbox(handle) {
|
|
120
121
|
const messages = loadMessages();
|
|
121
122
|
const h = handle.toLowerCase().replace('@', '');
|
|
122
123
|
|
|
@@ -126,17 +127,17 @@ function getInbox(handle) {
|
|
|
126
127
|
.sort((a, b) => b.timestamp - a.timestamp);
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
function getUnreadCount(handle) {
|
|
130
|
-
const inbox = getInbox(handle);
|
|
130
|
+
async function getUnreadCount(handle) {
|
|
131
|
+
const inbox = await getInbox(handle);
|
|
131
132
|
return inbox.filter(m => !m.read_at).length;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
// Alias for consistency with API store
|
|
135
|
-
function getRawInbox(handle) {
|
|
136
|
+
async function getRawInbox(handle) {
|
|
136
137
|
return getInbox(handle);
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
function getThread(myHandle, theirHandle) {
|
|
140
|
+
async function getThread(myHandle, theirHandle) {
|
|
140
141
|
const messages = loadMessages();
|
|
141
142
|
const me = myHandle.toLowerCase().replace('@', '');
|
|
142
143
|
const them = theirHandle.toLowerCase().replace('@', '');
|
|
@@ -150,7 +151,7 @@ function getThread(myHandle, theirHandle) {
|
|
|
150
151
|
.sort((a, b) => a.timestamp - b.timestamp);
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
function markThreadRead(myHandle, theirHandle) {
|
|
154
|
+
async function markThreadRead(myHandle, theirHandle) {
|
|
154
155
|
const messages = loadMessages();
|
|
155
156
|
const me = myHandle.toLowerCase().replace('@', '');
|
|
156
157
|
const them = theirHandle.toLowerCase().replace('@', '');
|
|
@@ -168,6 +169,52 @@ function markThreadRead(myHandle, theirHandle) {
|
|
|
168
169
|
fs.writeFileSync(MESSAGES_FILE, updated.map(m => JSON.stringify(m)).join('\n') + '\n');
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
// ============ SKILL EXCHANGES ============
|
|
173
|
+
|
|
174
|
+
function loadSkillExchanges() {
|
|
175
|
+
try {
|
|
176
|
+
if (fs.existsSync(SKILL_EXCHANGE_FILE)) {
|
|
177
|
+
const content = fs.readFileSync(SKILL_EXCHANGE_FILE, 'utf8');
|
|
178
|
+
return content.trim().split('\n')
|
|
179
|
+
.filter(line => line.length > 0)
|
|
180
|
+
.map(line => JSON.parse(line));
|
|
181
|
+
}
|
|
182
|
+
} catch (e) {}
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function appendSkillExchange(post) {
|
|
187
|
+
fs.appendFileSync(SKILL_EXCHANGE_FILE, JSON.stringify(post) + '\n');
|
|
188
|
+
return post;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function getSkillExchanges() {
|
|
192
|
+
return loadSkillExchanges();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ============ AWAY STATUS ============
|
|
196
|
+
|
|
197
|
+
let awayStatusCache = null;
|
|
198
|
+
|
|
199
|
+
async function setAwayStatus(handle, status, message = null) {
|
|
200
|
+
awayStatusCache = {
|
|
201
|
+
status,
|
|
202
|
+
message,
|
|
203
|
+
awayAt: new Date().toISOString()
|
|
204
|
+
};
|
|
205
|
+
return { success: true };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function getAwayStatus(handle) {
|
|
209
|
+
return awayStatusCache;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function clearAwayStatus(handle) {
|
|
213
|
+
const wasAway = awayStatusCache;
|
|
214
|
+
awayStatusCache = null;
|
|
215
|
+
return wasAway;
|
|
216
|
+
}
|
|
217
|
+
|
|
171
218
|
// ============ HELPERS ============
|
|
172
219
|
|
|
173
220
|
function formatTimeAgo(timestamp) {
|
|
@@ -202,6 +249,15 @@ module.exports = {
|
|
|
202
249
|
getThread,
|
|
203
250
|
markThreadRead,
|
|
204
251
|
|
|
252
|
+
// Skill Exchanges
|
|
253
|
+
appendSkillExchange,
|
|
254
|
+
getSkillExchanges,
|
|
255
|
+
|
|
256
|
+
// Away Status
|
|
257
|
+
setAwayStatus,
|
|
258
|
+
getAwayStatus,
|
|
259
|
+
clearAwayStatus,
|
|
260
|
+
|
|
205
261
|
// Helpers
|
|
206
262
|
formatTimeAgo
|
|
207
|
-
};
|
|
263
|
+
};
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User Profiles Store — Persistent user profiles with interests, tags, and projects
|
|
3
|
+
*
|
|
4
|
+
* Stores rich user data for matchmaking:
|
|
5
|
+
* - What they're building
|
|
6
|
+
* - Interests (broad topics they care about)
|
|
7
|
+
* - Tags (specific skills/technologies)
|
|
8
|
+
* - Activity patterns
|
|
9
|
+
* - Connection history
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const config = require('../config');
|
|
15
|
+
|
|
16
|
+
const PROFILES_FILE = path.join(config.VIBE_DIR, 'profiles.json');
|
|
17
|
+
|
|
18
|
+
// Load all profiles from disk
|
|
19
|
+
function loadProfiles() {
|
|
20
|
+
try {
|
|
21
|
+
if (fs.existsSync(PROFILES_FILE)) {
|
|
22
|
+
const data = fs.readFileSync(PROFILES_FILE, 'utf8');
|
|
23
|
+
return JSON.parse(data);
|
|
24
|
+
}
|
|
25
|
+
} catch (e) {
|
|
26
|
+
console.warn('Failed to load profiles:', e.message);
|
|
27
|
+
}
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Save all profiles to disk
|
|
32
|
+
function saveProfiles(profiles) {
|
|
33
|
+
try {
|
|
34
|
+
fs.writeFileSync(PROFILES_FILE, JSON.stringify(profiles, null, 2));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error('Failed to save profiles:', e.message);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Get a single user's profile
|
|
41
|
+
async function getProfile(handle) {
|
|
42
|
+
const profiles = loadProfiles();
|
|
43
|
+
const key = handle.toLowerCase().replace('@', '');
|
|
44
|
+
|
|
45
|
+
return profiles[key] || {
|
|
46
|
+
handle: key,
|
|
47
|
+
building: null,
|
|
48
|
+
interests: [],
|
|
49
|
+
tags: [],
|
|
50
|
+
lastSeen: null,
|
|
51
|
+
firstSeen: null,
|
|
52
|
+
connections: [],
|
|
53
|
+
ships: []
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Update user profile
|
|
58
|
+
async function updateProfile(handle, updates) {
|
|
59
|
+
const profiles = loadProfiles();
|
|
60
|
+
const key = handle.toLowerCase().replace('@', '');
|
|
61
|
+
|
|
62
|
+
const existing = profiles[key] || {
|
|
63
|
+
handle: key,
|
|
64
|
+
building: null,
|
|
65
|
+
interests: [],
|
|
66
|
+
tags: [],
|
|
67
|
+
lastSeen: null,
|
|
68
|
+
firstSeen: null,
|
|
69
|
+
connections: [],
|
|
70
|
+
ships: []
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Merge updates
|
|
74
|
+
const updated = { ...existing, ...updates };
|
|
75
|
+
|
|
76
|
+
// Ensure arrays are properly formatted
|
|
77
|
+
if (updates.interests) {
|
|
78
|
+
updated.interests = Array.isArray(updates.interests)
|
|
79
|
+
? updates.interests
|
|
80
|
+
: updates.interests.split(',').map(s => s.trim()).filter(s => s);
|
|
81
|
+
|
|
82
|
+
// IMPORTANT: Clear inferred flag when user explicitly sets interests
|
|
83
|
+
// This prevents inferMissingInterests() from overwriting explicit choices
|
|
84
|
+
updated.interests_inferred = false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (updates.tags) {
|
|
88
|
+
updated.tags = Array.isArray(updates.tags)
|
|
89
|
+
? updates.tags
|
|
90
|
+
: updates.tags.split(',').map(s => s.trim()).filter(s => s);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Update timestamps
|
|
94
|
+
updated.lastSeen = Date.now();
|
|
95
|
+
if (!existing.firstSeen) {
|
|
96
|
+
updated.firstSeen = Date.now();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
profiles[key] = updated;
|
|
100
|
+
saveProfiles(profiles);
|
|
101
|
+
|
|
102
|
+
return updated;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Get all profiles
|
|
106
|
+
async function getAllProfiles() {
|
|
107
|
+
const profiles = loadProfiles();
|
|
108
|
+
return Object.values(profiles);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Record a connection between users
|
|
112
|
+
async function recordConnection(from, to, reason) {
|
|
113
|
+
const profiles = loadProfiles();
|
|
114
|
+
const fromKey = from.toLowerCase().replace('@', '');
|
|
115
|
+
const toKey = to.toLowerCase().replace('@', '');
|
|
116
|
+
|
|
117
|
+
const connection = {
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
reason,
|
|
120
|
+
suggested_by: 'discovery-agent'
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Add to both profiles
|
|
124
|
+
if (!profiles[fromKey]) profiles[fromKey] = createEmptyProfile(fromKey);
|
|
125
|
+
if (!profiles[toKey]) profiles[toKey] = createEmptyProfile(toKey);
|
|
126
|
+
|
|
127
|
+
if (!profiles[fromKey].connections) profiles[fromKey].connections = [];
|
|
128
|
+
if (!profiles[toKey].connections) profiles[toKey].connections = [];
|
|
129
|
+
|
|
130
|
+
profiles[fromKey].connections.push({ handle: toKey, ...connection });
|
|
131
|
+
profiles[toKey].connections.push({ handle: fromKey, ...connection });
|
|
132
|
+
|
|
133
|
+
saveProfiles(profiles);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Record when someone ships something
|
|
137
|
+
async function recordShip(handle, what) {
|
|
138
|
+
const profiles = loadProfiles();
|
|
139
|
+
const key = handle.toLowerCase().replace('@', '');
|
|
140
|
+
|
|
141
|
+
if (!profiles[key]) {
|
|
142
|
+
profiles[key] = createEmptyProfile(key);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!profiles[key].ships) {
|
|
146
|
+
profiles[key].ships = [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
profiles[key].ships.unshift({
|
|
150
|
+
what,
|
|
151
|
+
timestamp: Date.now()
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Keep only last 10 ships
|
|
155
|
+
profiles[key].ships = profiles[key].ships.slice(0, 10);
|
|
156
|
+
|
|
157
|
+
saveProfiles(profiles);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Update last seen time
|
|
161
|
+
async function updateLastSeen(handle) {
|
|
162
|
+
const profiles = loadProfiles();
|
|
163
|
+
const key = handle.toLowerCase().replace('@', '');
|
|
164
|
+
|
|
165
|
+
if (profiles[key]) {
|
|
166
|
+
profiles[key].lastSeen = Date.now();
|
|
167
|
+
saveProfiles(profiles);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Get connection history between two users
|
|
172
|
+
async function getConnectionHistory(handle1, handle2) {
|
|
173
|
+
const profile = await getProfile(handle1);
|
|
174
|
+
const key2 = handle2.toLowerCase().replace('@', '');
|
|
175
|
+
|
|
176
|
+
return profile.connections?.filter(c => c.handle === key2) || [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Check if users have been connected before
|
|
180
|
+
async function hasBeenConnected(handle1, handle2) {
|
|
181
|
+
const history = await getConnectionHistory(handle1, handle2);
|
|
182
|
+
return history.length > 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Get users by interest
|
|
186
|
+
async function getUsersByInterest(interest) {
|
|
187
|
+
const profiles = await getAllProfiles();
|
|
188
|
+
const searchTerm = interest.toLowerCase();
|
|
189
|
+
|
|
190
|
+
return profiles.filter(p =>
|
|
191
|
+
p.interests?.some(i => i.toLowerCase().includes(searchTerm))
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Get users by tag
|
|
196
|
+
async function getUsersByTag(tag) {
|
|
197
|
+
const profiles = await getAllProfiles();
|
|
198
|
+
const searchTerm = tag.toLowerCase();
|
|
199
|
+
|
|
200
|
+
return profiles.filter(p =>
|
|
201
|
+
p.tags?.some(t => t.toLowerCase().includes(searchTerm))
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Get trending interests
|
|
206
|
+
async function getTrendingInterests() {
|
|
207
|
+
const profiles = await getAllProfiles();
|
|
208
|
+
const interestCount = {};
|
|
209
|
+
|
|
210
|
+
for (const profile of profiles) {
|
|
211
|
+
if (profile.interests) {
|
|
212
|
+
for (const interest of profile.interests) {
|
|
213
|
+
interestCount[interest] = (interestCount[interest] || 0) + 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return Object.entries(interestCount)
|
|
219
|
+
.sort(([,a], [,b]) => b - a)
|
|
220
|
+
.slice(0, 10)
|
|
221
|
+
.map(([interest, count]) => ({ interest, count }));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Get trending tags
|
|
225
|
+
async function getTrendingTags() {
|
|
226
|
+
const profiles = await getAllProfiles();
|
|
227
|
+
const tagCount = {};
|
|
228
|
+
|
|
229
|
+
for (const profile of profiles) {
|
|
230
|
+
if (profile.tags) {
|
|
231
|
+
for (const tag of profile.tags) {
|
|
232
|
+
tagCount[tag] = (tagCount[tag] || 0) + 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return Object.entries(tagCount)
|
|
238
|
+
.sort(([,a], [,b]) => b - a)
|
|
239
|
+
.slice(0, 15)
|
|
240
|
+
.map(([tag, count]) => ({ tag, count }));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Create empty profile structure
|
|
244
|
+
function createEmptyProfile(handle) {
|
|
245
|
+
return {
|
|
246
|
+
handle,
|
|
247
|
+
building: null,
|
|
248
|
+
interests: [],
|
|
249
|
+
tags: [],
|
|
250
|
+
lastSeen: Date.now(),
|
|
251
|
+
firstSeen: Date.now(),
|
|
252
|
+
connections: [],
|
|
253
|
+
ships: []
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Clean up old profiles (for maintenance)
|
|
258
|
+
async function cleanupOldProfiles(daysThreshold = 30) {
|
|
259
|
+
const profiles = loadProfiles();
|
|
260
|
+
const cutoff = Date.now() - (daysThreshold * 24 * 60 * 60 * 1000);
|
|
261
|
+
|
|
262
|
+
let cleaned = 0;
|
|
263
|
+
for (const [key, profile] of Object.entries(profiles)) {
|
|
264
|
+
if (profile.lastSeen && profile.lastSeen < cutoff) {
|
|
265
|
+
delete profiles[key];
|
|
266
|
+
cleaned++;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (cleaned > 0) {
|
|
271
|
+
saveProfiles(profiles);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return cleaned;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Sync profile data from API presence
|
|
279
|
+
* Called after fetching active users to keep local profiles up-to-date
|
|
280
|
+
*
|
|
281
|
+
* @param {Array} presenceData - Array of user objects from /api/presence
|
|
282
|
+
* @returns {number} Number of profiles synced
|
|
283
|
+
*/
|
|
284
|
+
async function syncFromPresence(presenceData) {
|
|
285
|
+
if (!presenceData || !Array.isArray(presenceData)) return 0;
|
|
286
|
+
|
|
287
|
+
const profiles = loadProfiles();
|
|
288
|
+
let changed = false;
|
|
289
|
+
let buildingsUpdated = 0;
|
|
290
|
+
|
|
291
|
+
for (const user of presenceData) {
|
|
292
|
+
// Skip users without a handle
|
|
293
|
+
if (!user.handle && !user.username) continue;
|
|
294
|
+
|
|
295
|
+
const handle = user.handle || user.username;
|
|
296
|
+
const key = handle.toLowerCase().replace('@', '');
|
|
297
|
+
|
|
298
|
+
// Create profile if it doesn't exist
|
|
299
|
+
const isNew = !profiles[key];
|
|
300
|
+
if (isNew) {
|
|
301
|
+
profiles[key] = createEmptyProfile(key);
|
|
302
|
+
changed = true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Sync building description (one_liner from presence)
|
|
306
|
+
const building = user.one_liner || user.workingOn;
|
|
307
|
+
if (building && building !== profiles[key].building) {
|
|
308
|
+
profiles[key].building = building;
|
|
309
|
+
buildingsUpdated++;
|
|
310
|
+
changed = true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Update timestamps (always sync these)
|
|
314
|
+
if (user.lastSeen) {
|
|
315
|
+
const ts = typeof user.lastSeen === 'number'
|
|
316
|
+
? user.lastSeen
|
|
317
|
+
: new Date(user.lastSeen).getTime();
|
|
318
|
+
// Only update if valid and different
|
|
319
|
+
if (!isNaN(ts) && ts !== profiles[key].lastSeen) {
|
|
320
|
+
profiles[key].lastSeen = ts;
|
|
321
|
+
changed = true;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (user.firstSeen && !profiles[key].firstSeen) {
|
|
325
|
+
const ts = typeof user.firstSeen === 'number'
|
|
326
|
+
? user.firstSeen
|
|
327
|
+
: new Date(user.firstSeen).getTime();
|
|
328
|
+
if (!isNaN(ts)) {
|
|
329
|
+
profiles[key].firstSeen = ts;
|
|
330
|
+
changed = true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Sync mood if present and different
|
|
335
|
+
if (user.mood && user.mood !== profiles[key].mood) {
|
|
336
|
+
profiles[key].mood = user.mood;
|
|
337
|
+
changed = true;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Only write if something actually changed
|
|
342
|
+
if (changed) {
|
|
343
|
+
saveProfiles(profiles);
|
|
344
|
+
if (buildingsUpdated > 0) {
|
|
345
|
+
console.error(`[profiles] Synced ${buildingsUpdated} building descriptions from presence`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return buildingsUpdated;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Infer interests from building descriptions for profiles that don't have them
|
|
354
|
+
* Uses suggestInterestsFromBuilding() from _discovery.js
|
|
355
|
+
*
|
|
356
|
+
* @returns {number} Number of profiles with newly inferred interests
|
|
357
|
+
*/
|
|
358
|
+
async function inferMissingInterests() {
|
|
359
|
+
// Import discovery module (lazy load to avoid circular deps)
|
|
360
|
+
let suggestInterestsFromBuilding;
|
|
361
|
+
try {
|
|
362
|
+
const discovery = require('../tools/_discovery');
|
|
363
|
+
suggestInterestsFromBuilding = discovery.suggestInterestsFromBuilding;
|
|
364
|
+
} catch (e) {
|
|
365
|
+
console.error('[profiles] Failed to load discovery module:', e.message);
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const profiles = loadProfiles();
|
|
370
|
+
let inferred = 0;
|
|
371
|
+
let changed = false;
|
|
372
|
+
|
|
373
|
+
for (const [key, profile] of Object.entries(profiles)) {
|
|
374
|
+
// Skip if user has explicit (non-inferred) interests
|
|
375
|
+
if (profile.interests?.length > 0 && !profile.interests_inferred) {
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Infer from building description
|
|
380
|
+
if (profile.building) {
|
|
381
|
+
const suggested = suggestInterestsFromBuilding(profile.building);
|
|
382
|
+
if (suggested.length > 0) {
|
|
383
|
+
// Only update if interests actually changed (avoid repeated writes)
|
|
384
|
+
const existingInterests = (profile.interests || []).slice().sort().join(',');
|
|
385
|
+
const newInterests = suggested.slice().sort().join(',');
|
|
386
|
+
|
|
387
|
+
if (existingInterests !== newInterests) {
|
|
388
|
+
profiles[key].interests = suggested;
|
|
389
|
+
profiles[key].interests_inferred = true; // Mark as auto-generated
|
|
390
|
+
profiles[key].interests_updated_at = Date.now();
|
|
391
|
+
inferred++;
|
|
392
|
+
changed = true;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (changed) {
|
|
399
|
+
saveProfiles(profiles);
|
|
400
|
+
console.error(`[profiles] Inferred interests for ${inferred} profiles`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return inferred;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Get profiles with inferred interests for discovery
|
|
408
|
+
* Combines explicit and inferred interests
|
|
409
|
+
*
|
|
410
|
+
* @returns {Array} All profiles with interests (explicit or inferred)
|
|
411
|
+
*/
|
|
412
|
+
async function getProfilesWithInterests() {
|
|
413
|
+
const profiles = await getAllProfiles();
|
|
414
|
+
return profiles.filter(p => p.interests?.length > 0);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
module.exports = {
|
|
418
|
+
getProfile,
|
|
419
|
+
updateProfile,
|
|
420
|
+
getAllProfiles,
|
|
421
|
+
recordConnection,
|
|
422
|
+
recordShip,
|
|
423
|
+
updateLastSeen,
|
|
424
|
+
getConnectionHistory,
|
|
425
|
+
hasBeenConnected,
|
|
426
|
+
getUsersByInterest,
|
|
427
|
+
getUsersByTag,
|
|
428
|
+
getTrendingInterests,
|
|
429
|
+
getTrendingTags,
|
|
430
|
+
cleanupOldProfiles,
|
|
431
|
+
// Presence sync (Phase 1)
|
|
432
|
+
syncFromPresence,
|
|
433
|
+
inferMissingInterests,
|
|
434
|
+
getProfilesWithInterests
|
|
435
|
+
};
|