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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -40
  3. package/config.js +171 -3
  4. package/index.js +136 -16
  5. package/intelligence/index.js +38 -0
  6. package/intelligence/infer.js +316 -0
  7. package/intelligence/patterns.js +651 -0
  8. package/intelligence/proactive.js +358 -0
  9. package/intelligence/serendipity.js +306 -0
  10. package/notify.js +141 -18
  11. package/package.json +8 -4
  12. package/presence.js +5 -1
  13. package/protocol/index.js +88 -1
  14. package/protocol/telegram-commands.js +199 -0
  15. package/store/api.js +360 -25
  16. package/store/index.js +7 -7
  17. package/store/local.js +67 -11
  18. package/store/profiles.js +287 -0
  19. package/store/reservations.js +321 -0
  20. package/store/skills.js +378 -0
  21. package/tools/_actions.js +270 -14
  22. package/tools/_connection-queue.js +257 -0
  23. package/tools/_discovery-enhanced.js +290 -0
  24. package/tools/_discovery.js +346 -0
  25. package/tools/_proactive-discovery.js +301 -0
  26. package/tools/admin-inbox.js +218 -0
  27. package/tools/agent-treasury.js +288 -0
  28. package/tools/agents.js +122 -0
  29. package/tools/arcade.js +173 -0
  30. package/tools/artifact-create.js +236 -0
  31. package/tools/artifact-view.js +174 -0
  32. package/tools/ask-expert.js +160 -0
  33. package/tools/auto-suggest-connections.js +304 -0
  34. package/tools/away.js +68 -0
  35. package/tools/back.js +51 -0
  36. package/tools/become-expert.js +150 -0
  37. package/tools/bootstrap-skills.js +231 -0
  38. package/tools/bridge-dashboard.js +342 -0
  39. package/tools/bridge-health.js +400 -0
  40. package/tools/bridge-live.js +384 -0
  41. package/tools/bridges.js +383 -0
  42. package/tools/bye.js +4 -0
  43. package/tools/collaborative-drawing.js +286 -0
  44. package/tools/colorguess.js +281 -0
  45. package/tools/crossword.js +369 -0
  46. package/tools/discover-insights.js +379 -0
  47. package/tools/discover-momentum.js +256 -0
  48. package/tools/discover.js +395 -0
  49. package/tools/discovery-analytics.js +345 -0
  50. package/tools/discovery-auto-suggest.js +275 -0
  51. package/tools/discovery-bootstrap.js +267 -0
  52. package/tools/discovery-daily.js +375 -0
  53. package/tools/discovery-dashboard.js +385 -0
  54. package/tools/discovery-digest.js +314 -0
  55. package/tools/discovery-hub.js +357 -0
  56. package/tools/discovery-insights.js +384 -0
  57. package/tools/discovery-momentum.js +281 -0
  58. package/tools/discovery-monitor.js +319 -0
  59. package/tools/discovery-proactive.js +300 -0
  60. package/tools/dm.js +62 -9
  61. package/tools/draw.js +317 -0
  62. package/tools/drawing.js +310 -0
  63. package/tools/echo.js +16 -0
  64. package/tools/farcaster.js +307 -0
  65. package/tools/feed.js +196 -0
  66. package/tools/game.js +218 -110
  67. package/tools/games-catalog.js +376 -0
  68. package/tools/games.js +313 -0
  69. package/tools/genesis.js +233 -0
  70. package/tools/guessnumber.js +194 -0
  71. package/tools/hangman.js +129 -0
  72. package/tools/help.js +269 -0
  73. package/tools/idea.js +210 -0
  74. package/tools/inbox.js +148 -25
  75. package/tools/init.js +651 -33
  76. package/tools/insights.js +123 -0
  77. package/tools/invite.js +142 -21
  78. package/tools/l2-bridge.js +272 -0
  79. package/tools/l2-status.js +217 -0
  80. package/tools/l2.js +206 -0
  81. package/tools/migrate.js +156 -0
  82. package/tools/mint.js +377 -0
  83. package/tools/multiplayer-game.js +275 -0
  84. package/tools/multiplayer-tictactoe.js +303 -0
  85. package/tools/mute.js +97 -0
  86. package/tools/notifications.js +415 -0
  87. package/tools/observe.js +200 -0
  88. package/tools/onboarding.js +147 -0
  89. package/tools/open.js +14 -2
  90. package/tools/party-game.js +314 -0
  91. package/tools/presence-agent.js +167 -0
  92. package/tools/profile.js +219 -0
  93. package/tools/pulse.js +218 -0
  94. package/tools/react.js +4 -0
  95. package/tools/release.js +83 -0
  96. package/tools/report.js +109 -0
  97. package/tools/reputation.js +175 -0
  98. package/tools/request.js +217 -0
  99. package/tools/reservations.js +116 -0
  100. package/tools/reserve.js +111 -0
  101. package/tools/riddle.js +240 -0
  102. package/tools/run-bootstrap.js +69 -0
  103. package/tools/settings.js +112 -0
  104. package/tools/ship.js +182 -0
  105. package/tools/shipback.js +326 -0
  106. package/tools/skills-analytics.js +349 -0
  107. package/tools/skills-bootstrap.js +301 -0
  108. package/tools/skills-dashboard.js +268 -0
  109. package/tools/skills-exchange.js +342 -0
  110. package/tools/skills.js +380 -0
  111. package/tools/smart-intro.js +353 -0
  112. package/tools/social-inbox.js +326 -69
  113. package/tools/social-post.js +251 -66
  114. package/tools/social-processor.js +445 -0
  115. package/tools/solo-game.js +390 -0
  116. package/tools/start.js +205 -83
  117. package/tools/storybuilder.js +331 -0
  118. package/tools/suggest-tags.js +186 -0
  119. package/tools/tag-suggestions.js +257 -0
  120. package/tools/telegram-bot.js +183 -0
  121. package/tools/telegram-setup.js +214 -0
  122. package/tools/tictactoe.js +155 -0
  123. package/tools/tip.js +120 -0
  124. package/tools/token.js +103 -0
  125. package/tools/twentyquestions.js +143 -0
  126. package/tools/wallet.js +127 -0
  127. package/tools/webhook-test.js +388 -0
  128. package/tools/who.js +118 -25
  129. package/tools/wordassociation.js +247 -0
  130. package/tools/workshop-buddy.js +394 -0
  131. package/tools/workshop.js +327 -0
  132. package/version.json +12 -3
  133. 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
+ };