slashvibe-mcp 0.2.2 → 0.2.3
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/LICENSE +21 -0
- package/README.md +58 -40
- package/config.js +171 -3
- package/index.js +136 -16
- package/intelligence/index.js +38 -0
- package/intelligence/infer.js +316 -0
- package/intelligence/patterns.js +651 -0
- package/intelligence/proactive.js +358 -0
- package/intelligence/serendipity.js +306 -0
- package/notify.js +141 -18
- package/package.json +8 -4
- package/presence.js +5 -1
- package/protocol/index.js +88 -1
- package/protocol/telegram-commands.js +199 -0
- package/store/api.js +360 -25
- package/store/index.js +7 -7
- package/store/local.js +67 -11
- package/store/profiles.js +287 -0
- package/store/reservations.js +321 -0
- package/store/skills.js +378 -0
- package/tools/_actions.js +270 -14
- package/tools/_connection-queue.js +257 -0
- package/tools/_discovery-enhanced.js +290 -0
- package/tools/_discovery.js +346 -0
- package/tools/_proactive-discovery.js +301 -0
- package/tools/admin-inbox.js +218 -0
- package/tools/agent-treasury.js +288 -0
- package/tools/agents.js +122 -0
- package/tools/arcade.js +173 -0
- package/tools/artifact-create.js +236 -0
- package/tools/artifact-view.js +174 -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/bye.js +4 -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 +395 -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 +62 -9
- package/tools/draw.js +317 -0
- package/tools/drawing.js +310 -0
- package/tools/echo.js +16 -0
- package/tools/farcaster.js +307 -0
- package/tools/feed.js +196 -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/guessnumber.js +194 -0
- package/tools/hangman.js +129 -0
- package/tools/help.js +269 -0
- package/tools/idea.js +210 -0
- package/tools/inbox.js +148 -25
- package/tools/init.js +651 -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 +14 -2
- package/tools/party-game.js +314 -0
- package/tools/presence-agent.js +167 -0
- package/tools/profile.js +219 -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 +217 -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/settings.js +112 -0
- package/tools/ship.js +182 -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 +205 -83
- package/tools/storybuilder.js +331 -0
- package/tools/suggest-tags.js +186 -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/wallet.js +127 -0
- package/tools/webhook-test.js +388 -0
- package/tools/who.js +118 -25
- package/tools/wordassociation.js +247 -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
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proactive Agent — Background social intelligence
|
|
3
|
+
*
|
|
4
|
+
* Generates unprompted social moments:
|
|
5
|
+
* - "Ships in the night" — what happened while you were away
|
|
6
|
+
* - Milestone detection — celebrate when someone ships
|
|
7
|
+
* - Connection nudges — suggest connections based on overlap
|
|
8
|
+
* - Break suggestions — nudge after long coding sessions
|
|
9
|
+
* - Welcome wagon — greet newcomers
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const store = require('../store');
|
|
13
|
+
const config = require('../config');
|
|
14
|
+
|
|
15
|
+
// Thresholds — conservative, non-intrusive
|
|
16
|
+
const CONFIG = {
|
|
17
|
+
BREAK_SUGGESTION_HOURS: 6, // Only after 6h (gentle)
|
|
18
|
+
AWAY_THRESHOLD_MINUTES: 60, // Consider "away" after 1h
|
|
19
|
+
RECENT_SHIP_MINUTES: 30, // "Recent" ship within 30min
|
|
20
|
+
WELCOME_WINDOW_HOURS: 4, // Welcome only very new users
|
|
21
|
+
MIN_SESSIONS_FOR_NUDGE: 5 // High bar for nudges
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Track state for proactive suggestions
|
|
25
|
+
let proactiveState = {
|
|
26
|
+
lastBreakSuggestion: null,
|
|
27
|
+
welcomedUsers: new Set(),
|
|
28
|
+
celebratedShips: new Set(),
|
|
29
|
+
lastSessionStart: null,
|
|
30
|
+
wasAway: false,
|
|
31
|
+
awayStartTime: null
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Generate proactive suggestions based on current state
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} context - Current user context
|
|
38
|
+
* @param {Array} activeUsers - All active users
|
|
39
|
+
* @returns {Array} Array of proactive suggestions
|
|
40
|
+
*/
|
|
41
|
+
async function generateProactiveSuggestions(context = {}) {
|
|
42
|
+
const suggestions = [];
|
|
43
|
+
const myHandle = config.getHandle();
|
|
44
|
+
if (!myHandle) return suggestions;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const activeUsers = await store.getActiveUsers();
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
|
|
50
|
+
// 1. Ships in the night (if returning from away)
|
|
51
|
+
const shipsInTheNight = await getShipsInTheNight(myHandle, activeUsers);
|
|
52
|
+
if (shipsInTheNight) {
|
|
53
|
+
suggestions.push(shipsInTheNight);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 2. Break suggestion (long session)
|
|
57
|
+
const breakSuggestion = checkBreakSuggestion(context);
|
|
58
|
+
if (breakSuggestion) {
|
|
59
|
+
suggestions.push(breakSuggestion);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Welcome wagon (greet newcomers)
|
|
63
|
+
const welcomes = getWelcomeWagon(myHandle, activeUsers);
|
|
64
|
+
suggestions.push(...welcomes);
|
|
65
|
+
|
|
66
|
+
// 4. Milestone celebration (someone shipped)
|
|
67
|
+
const milestones = getMilestoneCelebrations(myHandle, activeUsers);
|
|
68
|
+
suggestions.push(...milestones);
|
|
69
|
+
|
|
70
|
+
// 5. Connection nudges (based on overlapping work)
|
|
71
|
+
const nudges = getConnectionNudges(myHandle, activeUsers);
|
|
72
|
+
suggestions.push(...nudges);
|
|
73
|
+
|
|
74
|
+
return suggestions.filter(s => s !== null);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.error('[proactive] Error generating suggestions:', e.message);
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* "Ships in the Night" — What happened while you were away
|
|
83
|
+
*/
|
|
84
|
+
async function getShipsInTheNight(myHandle, activeUsers) {
|
|
85
|
+
// Check if user was recently away
|
|
86
|
+
const wasRecentlyAway = proactiveState.wasAway &&
|
|
87
|
+
proactiveState.awayStartTime &&
|
|
88
|
+
(Date.now() - proactiveState.awayStartTime) > CONFIG.AWAY_THRESHOLD_MINUTES * 60 * 1000;
|
|
89
|
+
|
|
90
|
+
if (!wasRecentlyAway) return null;
|
|
91
|
+
|
|
92
|
+
// Clear the away state
|
|
93
|
+
proactiveState.wasAway = false;
|
|
94
|
+
proactiveState.awayStartTime = null;
|
|
95
|
+
|
|
96
|
+
// Get recent activity
|
|
97
|
+
const recentEvents = [];
|
|
98
|
+
|
|
99
|
+
for (const user of activeUsers) {
|
|
100
|
+
if (user.handle === myHandle) continue;
|
|
101
|
+
|
|
102
|
+
// Check for recent ships
|
|
103
|
+
if (user.inferred_state === 'shipping' || user.builderMode === 'shipping') {
|
|
104
|
+
recentEvents.push({
|
|
105
|
+
type: 'shipped',
|
|
106
|
+
handle: user.handle,
|
|
107
|
+
context: user.note || user.file || 'something cool'
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check for new users who joined while away
|
|
112
|
+
if (user.firstSeen) {
|
|
113
|
+
const joinedAgo = (Date.now() - new Date(user.firstSeen).getTime()) / (1000 * 60);
|
|
114
|
+
if (joinedAgo < CONFIG.AWAY_THRESHOLD_MINUTES * 2) {
|
|
115
|
+
recentEvents.push({
|
|
116
|
+
type: 'joined',
|
|
117
|
+
handle: user.handle,
|
|
118
|
+
context: user.one_liner || 'Building something'
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (recentEvents.length === 0) return null;
|
|
125
|
+
|
|
126
|
+
// Format the summary
|
|
127
|
+
const shipped = recentEvents.filter(e => e.type === 'shipped');
|
|
128
|
+
const joined = recentEvents.filter(e => e.type === 'joined');
|
|
129
|
+
|
|
130
|
+
let text = '☕ **While you were away:**\n';
|
|
131
|
+
if (shipped.length > 0) {
|
|
132
|
+
text += shipped.map(s => ` • @${s.handle} shipped: ${s.context}`).join('\n') + '\n';
|
|
133
|
+
}
|
|
134
|
+
if (joined.length > 0) {
|
|
135
|
+
text += joined.map(j => ` • @${j.handle} joined: ${j.context}`).join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
type: 'ships_in_the_night',
|
|
140
|
+
priority: 1,
|
|
141
|
+
display: text,
|
|
142
|
+
events: recentEvents
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Break suggestion after long coding session
|
|
148
|
+
*/
|
|
149
|
+
function checkBreakSuggestion(context) {
|
|
150
|
+
const sessionStart = context.sessionStart || proactiveState.lastSessionStart;
|
|
151
|
+
if (!sessionStart) return null;
|
|
152
|
+
|
|
153
|
+
const sessionHours = (Date.now() - sessionStart) / (1000 * 60 * 60);
|
|
154
|
+
|
|
155
|
+
// Check if we've already suggested a break recently
|
|
156
|
+
if (proactiveState.lastBreakSuggestion) {
|
|
157
|
+
const hoursSinceLastSuggestion = (Date.now() - proactiveState.lastBreakSuggestion) / (1000 * 60 * 60);
|
|
158
|
+
if (hoursSinceLastSuggestion < 2) return null; // Don't nag
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (sessionHours >= CONFIG.BREAK_SUGGESTION_HOURS) {
|
|
162
|
+
proactiveState.lastBreakSuggestion = Date.now();
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
type: 'break_suggestion',
|
|
166
|
+
priority: 5,
|
|
167
|
+
display: `_${Math.floor(sessionHours)}h session_`,
|
|
168
|
+
sessionHours: Math.floor(sessionHours)
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Welcome wagon — greet newcomers
|
|
177
|
+
*/
|
|
178
|
+
function getWelcomeWagon(myHandle, activeUsers) {
|
|
179
|
+
const welcomes = [];
|
|
180
|
+
|
|
181
|
+
for (const user of activeUsers) {
|
|
182
|
+
if (user.handle === myHandle) continue;
|
|
183
|
+
if (proactiveState.welcomedUsers.has(user.handle)) continue;
|
|
184
|
+
|
|
185
|
+
// Check if user is new (within welcome window)
|
|
186
|
+
if (user.firstSeen) {
|
|
187
|
+
const hoursAgo = (Date.now() - new Date(user.firstSeen).getTime()) / (1000 * 60 * 60);
|
|
188
|
+
if (hoursAgo < CONFIG.WELCOME_WINDOW_HOURS && hoursAgo > 0) {
|
|
189
|
+
proactiveState.welcomedUsers.add(user.handle);
|
|
190
|
+
|
|
191
|
+
welcomes.push({
|
|
192
|
+
type: 'welcome',
|
|
193
|
+
priority: 2,
|
|
194
|
+
handle: user.handle,
|
|
195
|
+
display: `_@${user.handle} is new_`,
|
|
196
|
+
building: user.one_liner
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return welcomes.slice(0, 1); // Only one welcome at a time
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Milestone celebrations — someone shipped!
|
|
207
|
+
*/
|
|
208
|
+
function getMilestoneCelebrations(myHandle, activeUsers) {
|
|
209
|
+
const milestones = [];
|
|
210
|
+
|
|
211
|
+
for (const user of activeUsers) {
|
|
212
|
+
if (user.handle === myHandle) continue;
|
|
213
|
+
|
|
214
|
+
// Check for shipping indicators
|
|
215
|
+
const isShipping = user.inferred_state === 'shipping' ||
|
|
216
|
+
user.mood === '🚀' ||
|
|
217
|
+
user.builderMode === 'shipping';
|
|
218
|
+
|
|
219
|
+
if (isShipping) {
|
|
220
|
+
const shipKey = `${user.handle}:${Date.now() / (1000 * 60 * 60) | 0}`; // Hourly key
|
|
221
|
+
if (proactiveState.celebratedShips.has(shipKey)) continue;
|
|
222
|
+
|
|
223
|
+
proactiveState.celebratedShips.add(shipKey);
|
|
224
|
+
|
|
225
|
+
// Limit cached ships to prevent memory growth
|
|
226
|
+
if (proactiveState.celebratedShips.size > 100) {
|
|
227
|
+
const oldest = [...proactiveState.celebratedShips][0];
|
|
228
|
+
proactiveState.celebratedShips.delete(oldest);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
milestones.push({
|
|
232
|
+
type: 'milestone',
|
|
233
|
+
priority: 3,
|
|
234
|
+
handle: user.handle,
|
|
235
|
+
display: `_@${user.handle} shipping_`,
|
|
236
|
+
context: user.note || user.file
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return milestones.slice(0, 1); // One celebration at a time
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Connection nudges based on overlapping interests
|
|
246
|
+
*/
|
|
247
|
+
function getConnectionNudges(myHandle, activeUsers) {
|
|
248
|
+
// This would ideally use stored interaction history
|
|
249
|
+
// For now, just suggest based on similar work patterns
|
|
250
|
+
const nudges = [];
|
|
251
|
+
const myUser = activeUsers.find(u => u.handle === myHandle);
|
|
252
|
+
if (!myUser) return nudges;
|
|
253
|
+
|
|
254
|
+
for (const user of activeUsers) {
|
|
255
|
+
if (user.handle === myHandle) continue;
|
|
256
|
+
|
|
257
|
+
// Both in same inferred state
|
|
258
|
+
if (myUser.inferred_state && user.inferred_state &&
|
|
259
|
+
myUser.inferred_state === user.inferred_state &&
|
|
260
|
+
['debugging', 'deep-focus', 'shipping'].includes(myUser.inferred_state)) {
|
|
261
|
+
|
|
262
|
+
nudges.push({
|
|
263
|
+
type: 'connection_nudge',
|
|
264
|
+
priority: 4,
|
|
265
|
+
handle: user.handle,
|
|
266
|
+
display: `\n💫 You and @${user.handle} are both in ${myUser.inferred_state.replace('-', ' ')} mode`,
|
|
267
|
+
reason: 'same_state'
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return nudges.slice(0, 1); // One nudge at a time
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Mark user as away (for ships in the night tracking)
|
|
277
|
+
*/
|
|
278
|
+
function markAway() {
|
|
279
|
+
proactiveState.wasAway = true;
|
|
280
|
+
proactiveState.awayStartTime = Date.now();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Mark user as back
|
|
285
|
+
*/
|
|
286
|
+
function markBack() {
|
|
287
|
+
// Don't clear immediately - let getShipsInTheNight process it first
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Set session start time
|
|
292
|
+
*/
|
|
293
|
+
function setSessionStart(timestamp = Date.now()) {
|
|
294
|
+
proactiveState.lastSessionStart = timestamp;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get proactive summary for display
|
|
299
|
+
* Returns formatted string of all relevant suggestions
|
|
300
|
+
*/
|
|
301
|
+
async function getProactiveSummary(context = {}) {
|
|
302
|
+
const suggestions = await generateProactiveSuggestions(context);
|
|
303
|
+
|
|
304
|
+
if (suggestions.length === 0) return '';
|
|
305
|
+
|
|
306
|
+
// Sort by priority and format
|
|
307
|
+
suggestions.sort((a, b) => a.priority - b.priority);
|
|
308
|
+
|
|
309
|
+
// Only show top 2 suggestions to avoid overwhelm
|
|
310
|
+
return suggestions
|
|
311
|
+
.slice(0, 2)
|
|
312
|
+
.map(s => s.display)
|
|
313
|
+
.join('\n');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Check for proactive opportunities on tool calls
|
|
318
|
+
* Called from main index.js to inject proactive elements
|
|
319
|
+
*/
|
|
320
|
+
async function checkProactiveOpportunities(toolName, args = {}) {
|
|
321
|
+
// Track away/back transitions
|
|
322
|
+
if (toolName === 'vibe_away') {
|
|
323
|
+
markAway();
|
|
324
|
+
} else if (toolName === 'vibe_back') {
|
|
325
|
+
// Don't mark back yet - let ships_in_the_night process
|
|
326
|
+
} else if (toolName === 'vibe_init' || toolName === 'vibe_start') {
|
|
327
|
+
setSessionStart();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Only generate suggestions for certain tools
|
|
331
|
+
const socialTools = ['vibe_who', 'vibe_inbox', 'vibe_start'];
|
|
332
|
+
if (!socialTools.includes(toolName)) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return await getProactiveSummary(args);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = {
|
|
340
|
+
generateProactiveSuggestions,
|
|
341
|
+
getProactiveSummary,
|
|
342
|
+
checkProactiveOpportunities,
|
|
343
|
+
markAway,
|
|
344
|
+
markBack,
|
|
345
|
+
setSessionStart,
|
|
346
|
+
// Export state for testing
|
|
347
|
+
_getState: () => proactiveState,
|
|
348
|
+
_resetState: () => {
|
|
349
|
+
proactiveState = {
|
|
350
|
+
lastBreakSuggestion: null,
|
|
351
|
+
welcomedUsers: new Set(),
|
|
352
|
+
celebratedShips: new Set(),
|
|
353
|
+
lastSessionStart: null,
|
|
354
|
+
wasAway: false,
|
|
355
|
+
awayStartTime: null
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
};
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serendipity Engine — Surface meaningful coincidences
|
|
3
|
+
*
|
|
4
|
+
* Detects when users are working on related things:
|
|
5
|
+
* - Same file or module
|
|
6
|
+
* - Similar branch names (both working on auth)
|
|
7
|
+
* - Same/similar errors
|
|
8
|
+
* - Related packages/dependencies
|
|
9
|
+
*
|
|
10
|
+
* Returns "serendipity moments" that can be surfaced to users
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Find serendipity matches between current user and all others
|
|
15
|
+
*
|
|
16
|
+
* @param {Object} currentUser - The user to find matches for
|
|
17
|
+
* @param {Array} allUsers - All active users
|
|
18
|
+
* @returns {Array} Serendipity moments sorted by relevance
|
|
19
|
+
*/
|
|
20
|
+
function findSerendipity(currentUser, allUsers) {
|
|
21
|
+
const matches = [];
|
|
22
|
+
const myHandle = currentUser.handle;
|
|
23
|
+
|
|
24
|
+
for (const other of allUsers) {
|
|
25
|
+
if (other.handle === myHandle) continue;
|
|
26
|
+
|
|
27
|
+
// 1. Same file or module
|
|
28
|
+
if (currentUser.file && other.file) {
|
|
29
|
+
const myModule = getModule(currentUser.file);
|
|
30
|
+
const theirModule = getModule(other.file);
|
|
31
|
+
|
|
32
|
+
if (currentUser.file === other.file) {
|
|
33
|
+
// Exact same file - high signal!
|
|
34
|
+
matches.push({
|
|
35
|
+
type: 'same_file',
|
|
36
|
+
with: other.handle,
|
|
37
|
+
context: `both editing ${currentUser.file}`,
|
|
38
|
+
relevance: 0.95,
|
|
39
|
+
suggestion: `You're both in ${currentUser.file} right now!`,
|
|
40
|
+
action: 'pair'
|
|
41
|
+
});
|
|
42
|
+
} else if (myModule && theirModule && myModule === theirModule) {
|
|
43
|
+
// Same module/directory
|
|
44
|
+
matches.push({
|
|
45
|
+
type: 'same_module',
|
|
46
|
+
with: other.handle,
|
|
47
|
+
context: `both working in ${myModule}/`,
|
|
48
|
+
relevance: 0.75,
|
|
49
|
+
suggestion: `You're both in the ${myModule} module`,
|
|
50
|
+
action: 'connect'
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. Similar branch names
|
|
56
|
+
if (currentUser.branch && other.branch) {
|
|
57
|
+
const myTopic = extractBranchTopic(currentUser.branch);
|
|
58
|
+
const theirTopic = extractBranchTopic(other.branch);
|
|
59
|
+
|
|
60
|
+
if (myTopic && theirTopic && myTopic === theirTopic) {
|
|
61
|
+
matches.push({
|
|
62
|
+
type: 'same_topic',
|
|
63
|
+
with: other.handle,
|
|
64
|
+
context: `both working on ${myTopic}`,
|
|
65
|
+
relevance: 0.8,
|
|
66
|
+
suggestion: `You're both working on ${myTopic}-related code`,
|
|
67
|
+
action: 'connect'
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. Similar errors
|
|
73
|
+
if (currentUser.error && other.error) {
|
|
74
|
+
const similarity = errorSimilarity(currentUser.error, other.error);
|
|
75
|
+
if (similarity > 0.6) {
|
|
76
|
+
matches.push({
|
|
77
|
+
type: 'same_struggle',
|
|
78
|
+
with: other.handle,
|
|
79
|
+
context: `both hitting similar errors`,
|
|
80
|
+
relevance: 0.85,
|
|
81
|
+
suggestion: `@${other.handle} is seeing a similar error`,
|
|
82
|
+
action: 'help',
|
|
83
|
+
detail: other.error.slice(0, 50)
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 4. Complementary work (one debugging, one shipping same area)
|
|
89
|
+
if (currentUser.branch && other.branch) {
|
|
90
|
+
const myBranchType = getBranchType(currentUser.branch);
|
|
91
|
+
const theirBranchType = getBranchType(other.branch);
|
|
92
|
+
const myTopic = extractBranchTopic(currentUser.branch);
|
|
93
|
+
const theirTopic = extractBranchTopic(other.branch);
|
|
94
|
+
|
|
95
|
+
// You're fixing something they're building (or vice versa)
|
|
96
|
+
if (myTopic === theirTopic && myBranchType !== theirBranchType) {
|
|
97
|
+
if (myBranchType === 'fix' && theirBranchType === 'feature') {
|
|
98
|
+
matches.push({
|
|
99
|
+
type: 'complementary',
|
|
100
|
+
with: other.handle,
|
|
101
|
+
context: `you're fixing ${myTopic}, they're building it`,
|
|
102
|
+
relevance: 0.7,
|
|
103
|
+
suggestion: `Heads up: @${other.handle} is building ${myTopic} features`,
|
|
104
|
+
action: 'inform'
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 5. Both just joined recently (newbie solidarity)
|
|
111
|
+
if (isNewUser(currentUser) && isNewUser(other)) {
|
|
112
|
+
matches.push({
|
|
113
|
+
type: 'both_new',
|
|
114
|
+
with: other.handle,
|
|
115
|
+
context: 'both new to /vibe',
|
|
116
|
+
relevance: 0.5,
|
|
117
|
+
suggestion: `@${other.handle} is also new here`,
|
|
118
|
+
action: 'welcome'
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Sort by relevance
|
|
124
|
+
return matches.sort((a, b) => b.relevance - a.relevance);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Extract module/directory from file path
|
|
129
|
+
*/
|
|
130
|
+
function getModule(filePath) {
|
|
131
|
+
if (!filePath) return null;
|
|
132
|
+
|
|
133
|
+
// Handle common patterns
|
|
134
|
+
const parts = filePath.split('/');
|
|
135
|
+
|
|
136
|
+
// Look for meaningful directories
|
|
137
|
+
const meaningfulDirs = ['src', 'lib', 'app', 'components', 'pages', 'api', 'services', 'utils', 'hooks', 'store', 'models', 'controllers'];
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
140
|
+
if (meaningfulDirs.includes(parts[i])) {
|
|
141
|
+
// Return next directory after meaningful one
|
|
142
|
+
return parts[i + 1] || parts[i];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Default to parent directory
|
|
147
|
+
return parts.length >= 2 ? parts[parts.length - 2] : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Extract topic from branch name
|
|
152
|
+
* e.g., "fix-auth-bug" -> "auth"
|
|
153
|
+
* e.g., "feat/user-profile" -> "user-profile"
|
|
154
|
+
*/
|
|
155
|
+
function extractBranchTopic(branch) {
|
|
156
|
+
if (!branch) return null;
|
|
157
|
+
|
|
158
|
+
// Remove common prefixes
|
|
159
|
+
const cleaned = branch
|
|
160
|
+
.replace(/^(fix|feat|feature|bug|hotfix|chore|refactor|docs)[-_\/]?/i, '')
|
|
161
|
+
.replace(/[-_\/]/g, '-')
|
|
162
|
+
.toLowerCase();
|
|
163
|
+
|
|
164
|
+
// Extract first meaningful segment
|
|
165
|
+
const parts = cleaned.split('-').filter(p => p.length > 2);
|
|
166
|
+
|
|
167
|
+
// Common topic keywords
|
|
168
|
+
const topics = ['auth', 'user', 'api', 'db', 'database', 'ui', 'test', 'config', 'payment', 'email', 'notification', 'search', 'cache', 'session', 'login', 'signup'];
|
|
169
|
+
|
|
170
|
+
// Look for topic keywords
|
|
171
|
+
for (const part of parts) {
|
|
172
|
+
if (topics.includes(part)) {
|
|
173
|
+
return part;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Return first segment if no topic found
|
|
178
|
+
return parts[0] || null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get branch type (fix, feature, etc.)
|
|
183
|
+
*/
|
|
184
|
+
function getBranchType(branch) {
|
|
185
|
+
if (!branch) return 'unknown';
|
|
186
|
+
|
|
187
|
+
const lower = branch.toLowerCase();
|
|
188
|
+
if (/^(fix|bug|hotfix)[-_\/]/.test(lower)) return 'fix';
|
|
189
|
+
if (/^(feat|feature)[-_\/]/.test(lower)) return 'feature';
|
|
190
|
+
if (/^(chore|refactor)[-_\/]/.test(lower)) return 'chore';
|
|
191
|
+
if (['main', 'master', 'production', 'prod'].includes(lower)) return 'main';
|
|
192
|
+
|
|
193
|
+
return 'feature'; // Default to feature
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Calculate similarity between two error messages
|
|
198
|
+
*/
|
|
199
|
+
function errorSimilarity(error1, error2) {
|
|
200
|
+
if (!error1 || !error2) return 0;
|
|
201
|
+
|
|
202
|
+
// Normalize errors
|
|
203
|
+
const normalize = (err) => err
|
|
204
|
+
.toLowerCase()
|
|
205
|
+
.replace(/[0-9]+/g, 'N') // Replace numbers
|
|
206
|
+
.replace(/['"`]/g, '') // Remove quotes
|
|
207
|
+
.replace(/\s+/g, ' ') // Normalize whitespace
|
|
208
|
+
.slice(0, 100); // Truncate
|
|
209
|
+
|
|
210
|
+
const e1 = normalize(error1);
|
|
211
|
+
const e2 = normalize(error2);
|
|
212
|
+
|
|
213
|
+
if (e1 === e2) return 1.0;
|
|
214
|
+
|
|
215
|
+
// Check for common error types
|
|
216
|
+
const errorTypes = [
|
|
217
|
+
'TypeError', 'SyntaxError', 'ReferenceError', 'RangeError',
|
|
218
|
+
'undefined is not', 'cannot read property', 'is not a function',
|
|
219
|
+
'module not found', 'cannot find module', 'ENOENT', 'ECONNREFUSED'
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
for (const type of errorTypes) {
|
|
223
|
+
const typeLower = type.toLowerCase();
|
|
224
|
+
if (e1.includes(typeLower) && e2.includes(typeLower)) {
|
|
225
|
+
return 0.7;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Simple word overlap
|
|
230
|
+
const words1 = new Set(e1.split(' ').filter(w => w.length > 3));
|
|
231
|
+
const words2 = new Set(e2.split(' ').filter(w => w.length > 3));
|
|
232
|
+
const overlap = [...words1].filter(w => words2.has(w)).length;
|
|
233
|
+
const union = new Set([...words1, ...words2]).size;
|
|
234
|
+
|
|
235
|
+
return union > 0 ? overlap / union : 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Check if user is new (first seen within last 24 hours)
|
|
240
|
+
*/
|
|
241
|
+
function isNewUser(user) {
|
|
242
|
+
if (!user.firstSeen) return false;
|
|
243
|
+
|
|
244
|
+
const firstSeen = new Date(user.firstSeen).getTime();
|
|
245
|
+
const hoursAgo = (Date.now() - firstSeen) / (1000 * 60 * 60);
|
|
246
|
+
|
|
247
|
+
return hoursAgo < 24;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Format serendipity moment for display
|
|
252
|
+
*/
|
|
253
|
+
function formatSerendipityMoment(moment) {
|
|
254
|
+
const emoji = {
|
|
255
|
+
'same_file': '✨',
|
|
256
|
+
'same_module': '🔗',
|
|
257
|
+
'same_topic': '🎯',
|
|
258
|
+
'same_struggle': '🤝',
|
|
259
|
+
'complementary': '💡',
|
|
260
|
+
'both_new': '👋'
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
emoji: emoji[moment.type] || '💫',
|
|
265
|
+
text: moment.suggestion,
|
|
266
|
+
action: moment.action,
|
|
267
|
+
handle: moment.with,
|
|
268
|
+
relevance: moment.relevance
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get top serendipity moment for a user
|
|
274
|
+
* Returns null if no strong matches
|
|
275
|
+
*/
|
|
276
|
+
function getTopSerendipity(currentUser, allUsers) {
|
|
277
|
+
const matches = findSerendipity(currentUser, allUsers);
|
|
278
|
+
|
|
279
|
+
// Only surface if relevance is high enough
|
|
280
|
+
if (matches.length === 0 || matches[0].relevance < 0.6) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return formatSerendipityMoment(matches[0]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get all serendipity moments above threshold
|
|
289
|
+
*/
|
|
290
|
+
function getAllSerendipity(currentUser, allUsers, minRelevance = 0.5) {
|
|
291
|
+
const matches = findSerendipity(currentUser, allUsers);
|
|
292
|
+
return matches
|
|
293
|
+
.filter(m => m.relevance >= minRelevance)
|
|
294
|
+
.map(formatSerendipityMoment);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = {
|
|
298
|
+
findSerendipity,
|
|
299
|
+
getTopSerendipity,
|
|
300
|
+
getAllSerendipity,
|
|
301
|
+
formatSerendipityMoment,
|
|
302
|
+
// Utilities for testing
|
|
303
|
+
getModule,
|
|
304
|
+
extractBranchTopic,
|
|
305
|
+
errorSimilarity
|
|
306
|
+
};
|