slashvibe-mcp 0.2.3 → 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.
Files changed (54) hide show
  1. package/README.md +41 -58
  2. package/analytics.js +107 -0
  3. package/config.js +5 -2
  4. package/index.js +60 -51
  5. package/intelligence/index.js +9 -2
  6. package/intelligence/interests.js +369 -0
  7. package/notification-emitter.js +77 -0
  8. package/package.json +7 -3
  9. package/store/api.js +109 -4
  10. package/store/profiles.js +160 -12
  11. package/tools/_actions.js +230 -17
  12. package/tools/_discovery.js +119 -26
  13. package/tools/_shared/index.js +64 -0
  14. package/tools/_work-context.js +338 -0
  15. package/tools/_work-context.manual-test.js +199 -0
  16. package/tools/_work-context.test.js +260 -0
  17. package/tools/analytics.js +191 -0
  18. package/tools/approve.js +197 -0
  19. package/tools/artifact-create.js +14 -3
  20. package/tools/artifacts-price.js +107 -0
  21. package/tools/broadcast.js +286 -0
  22. package/tools/chat.js +202 -0
  23. package/tools/discover.js +314 -34
  24. package/tools/dm.js +22 -5
  25. package/tools/earnings.js +126 -0
  26. package/tools/feed.js +22 -3
  27. package/tools/follow.js +224 -0
  28. package/tools/friends.js +192 -0
  29. package/tools/gig-browse.js +206 -0
  30. package/tools/gig-complete.js +139 -0
  31. package/tools/help.js +2 -2
  32. package/tools/idea.js +8 -1
  33. package/tools/inbox.js +248 -105
  34. package/tools/init.js +7 -1
  35. package/tools/open.js +42 -5
  36. package/tools/plan.js +225 -0
  37. package/tools/proof-of-work.js +139 -0
  38. package/tools/request.js +16 -2
  39. package/tools/schedule.js +367 -0
  40. package/tools/session.js +420 -0
  41. package/tools/session_price.js +128 -0
  42. package/tools/settings.js +90 -2
  43. package/tools/ship.js +8 -2
  44. package/tools/start.js +96 -3
  45. package/tools/status.js +53 -6
  46. package/tools/stuck.js +297 -0
  47. package/tools/subscribe.js +148 -0
  48. package/tools/subscriptions.js +134 -0
  49. package/tools/suggest-tags.js +6 -8
  50. package/tools/update.js +1 -1
  51. package/tools/watch.js +157 -0
  52. package/tools/withdraw.js +145 -0
  53. package/tools/work-summary.js +96 -0
  54. package/LICENSE +0 -21
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Interest Inference — Derive interests from live context signals
3
+ *
4
+ * Complements static inference (from building descriptions) with DYNAMIC
5
+ * interests inferred from what users are ACTUALLY doing:
6
+ * - Current file/module they're editing
7
+ * - Branch name they're working on
8
+ * - Activity patterns (shipping, debugging, etc.)
9
+ *
10
+ * This creates a more accurate picture of interests that updates in real-time.
11
+ */
12
+
13
+ const { getModule, extractBranchTopic } = require('./serendipity');
14
+
15
+ // Map file modules to interest categories
16
+ const MODULE_TO_INTERESTS = {
17
+ // Code organization
18
+ 'agents': ['agents', 'ai', 'automation'],
19
+ 'agent': ['agents', 'ai', 'automation'],
20
+ 'mcp': ['agents', 'mcp', 'ai'],
21
+ 'ai': ['ai', 'machine learning'],
22
+ 'ml': ['machine learning', 'ai', 'data science'],
23
+ 'llm': ['ai', 'machine learning'],
24
+
25
+ // Web development
26
+ 'api': ['backend', 'api'],
27
+ 'components': ['frontend', 'web development'],
28
+ 'pages': ['frontend', 'web development'],
29
+ 'views': ['frontend', 'web development'],
30
+ 'hooks': ['frontend', 'react'],
31
+ 'store': ['frontend', 'state management'],
32
+ 'redux': ['frontend', 'state management'],
33
+
34
+ // Backend
35
+ 'server': ['backend', 'api'],
36
+ 'services': ['backend', 'microservices'],
37
+ 'controllers': ['backend', 'api'],
38
+ 'models': ['backend', 'database'],
39
+ 'db': ['database', 'backend'],
40
+ 'database': ['database', 'backend'],
41
+
42
+ // Infrastructure
43
+ 'infra': ['infrastructure', 'devops'],
44
+ 'deploy': ['devops', 'infrastructure'],
45
+ 'docker': ['devops', 'containers'],
46
+ 'k8s': ['devops', 'kubernetes'],
47
+ 'terraform': ['infrastructure', 'devops'],
48
+ 'ci': ['devops', 'automation'],
49
+
50
+ // Testing
51
+ 'tests': ['testing', 'quality'],
52
+ 'test': ['testing', 'quality'],
53
+ '__tests__': ['testing', 'quality'],
54
+ 'spec': ['testing', 'quality'],
55
+
56
+ // Mobile
57
+ 'ios': ['mobile development', 'ios'],
58
+ 'android': ['mobile development', 'android'],
59
+ 'mobile': ['mobile development'],
60
+
61
+ // Data
62
+ 'data': ['data science', 'analytics'],
63
+ 'analytics': ['analytics', 'data science'],
64
+
65
+ // Security
66
+ 'auth': ['security', 'authentication'],
67
+ 'security': ['security'],
68
+
69
+ // Tools
70
+ 'tools': ['developer tools'],
71
+ 'cli': ['developer tools', 'cli'],
72
+ 'scripts': ['developer tools', 'automation'],
73
+
74
+ // Content
75
+ 'docs': ['documentation', 'writing'],
76
+ 'content': ['content creation'],
77
+
78
+ // Games
79
+ 'game': ['game development', 'gaming'],
80
+ 'games': ['game development', 'gaming'],
81
+
82
+ // Design
83
+ 'design': ['design', 'creative'],
84
+ 'styles': ['frontend', 'design'],
85
+ 'ui': ['design', 'frontend']
86
+ };
87
+
88
+ // Map branch topics to interest categories
89
+ const TOPIC_TO_INTERESTS = {
90
+ 'auth': ['security', 'authentication'],
91
+ 'login': ['security', 'authentication'],
92
+ 'signup': ['security', 'authentication'],
93
+ 'user': ['user experience', 'product'],
94
+ 'ui': ['frontend', 'design'],
95
+ 'api': ['backend', 'api'],
96
+ 'db': ['database', 'backend'],
97
+ 'database': ['database', 'backend'],
98
+ 'ml': ['machine learning', 'ai'],
99
+ 'ai': ['ai', 'machine learning'],
100
+ 'agent': ['agents', 'ai', 'automation'],
101
+ 'test': ['testing', 'quality'],
102
+ 'config': ['infrastructure', 'devops'],
103
+ 'payment': ['fintech', 'product'],
104
+ 'email': ['communication', 'backend'],
105
+ 'notification': ['communication', 'product'],
106
+ 'search': ['search', 'backend'],
107
+ 'cache': ['performance', 'backend'],
108
+ 'session': ['backend', 'security'],
109
+ 'deploy': ['devops', 'infrastructure'],
110
+ 'perf': ['performance', 'optimization'],
111
+ 'mobile': ['mobile development'],
112
+ 'social': ['social', 'community']
113
+ };
114
+
115
+ // Map file extensions to interests
116
+ const EXTENSION_TO_INTERESTS = {
117
+ '.py': ['python'],
118
+ '.js': ['javascript'],
119
+ '.ts': ['typescript'],
120
+ '.tsx': ['react', 'typescript'],
121
+ '.jsx': ['react', 'javascript'],
122
+ '.go': ['go'],
123
+ '.rs': ['rust'],
124
+ '.rb': ['ruby'],
125
+ '.java': ['java'],
126
+ '.swift': ['swift', 'ios'],
127
+ '.kt': ['kotlin', 'android'],
128
+ '.sol': ['solidity', 'web3'],
129
+ '.vue': ['vue', 'frontend'],
130
+ '.svelte': ['svelte', 'frontend'],
131
+ '.css': ['css', 'frontend'],
132
+ '.scss': ['css', 'frontend'],
133
+ '.sql': ['sql', 'database'],
134
+ '.prisma': ['database', 'backend'],
135
+ '.graphql': ['graphql', 'api'],
136
+ '.proto': ['grpc', 'backend'],
137
+ '.yml': ['devops', 'infrastructure'],
138
+ '.yaml': ['devops', 'infrastructure'],
139
+ '.tf': ['terraform', 'infrastructure'],
140
+ '.md': ['documentation', 'writing']
141
+ };
142
+
143
+ /**
144
+ * Infer interests from user's current context
145
+ * Returns array of interests with confidence scores and sources
146
+ *
147
+ * @param {Object} context - User's current context
148
+ * @param {string} context.file - Current file path
149
+ * @param {string} context.branch - Current branch name
150
+ * @param {string} context.mood - Current mood/state
151
+ * @param {string} context.error - Current error (if any)
152
+ * @returns {Array} Array of { interest, source, confidence }
153
+ */
154
+ function inferLiveInterests(context) {
155
+ if (!context) return [];
156
+
157
+ const interests = [];
158
+
159
+ // 1. Infer from current file/module
160
+ if (context.file) {
161
+ const module = getModule(context.file);
162
+ if (module && MODULE_TO_INTERESTS[module.toLowerCase()]) {
163
+ const moduleInterests = MODULE_TO_INTERESTS[module.toLowerCase()];
164
+ interests.push(...moduleInterests.map(i => ({
165
+ interest: i,
166
+ source: 'file',
167
+ confidence: 0.7,
168
+ detail: `editing ${module}/`
169
+ })));
170
+ }
171
+
172
+ // Also check file extension
173
+ const ext = getFileExtension(context.file);
174
+ if (ext && EXTENSION_TO_INTERESTS[ext]) {
175
+ interests.push(...EXTENSION_TO_INTERESTS[ext].map(i => ({
176
+ interest: i,
177
+ source: 'extension',
178
+ confidence: 0.5,
179
+ detail: `working with ${ext} files`
180
+ })));
181
+ }
182
+ }
183
+
184
+ // 2. Infer from branch name
185
+ if (context.branch) {
186
+ const topic = extractBranchTopic(context.branch);
187
+ if (topic && TOPIC_TO_INTERESTS[topic]) {
188
+ interests.push(...TOPIC_TO_INTERESTS[topic].map(i => ({
189
+ interest: i,
190
+ source: 'branch',
191
+ confidence: 0.8,
192
+ detail: `on ${context.branch}`
193
+ })));
194
+ }
195
+
196
+ // Special branch patterns
197
+ if (/agent|mcp|claude/i.test(context.branch)) {
198
+ interests.push({
199
+ interest: 'agents',
200
+ source: 'branch',
201
+ confidence: 0.9,
202
+ detail: `agent-related branch`
203
+ });
204
+ }
205
+ }
206
+
207
+ // 3. Infer from activity state (mood)
208
+ if (context.mood) {
209
+ const moodInterests = inferFromMood(context.mood);
210
+ interests.push(...moodInterests);
211
+ }
212
+
213
+ // 4. Infer from error (if debugging)
214
+ if (context.error) {
215
+ const errorInterests = inferFromError(context.error);
216
+ interests.push(...errorInterests);
217
+ }
218
+
219
+ // Dedupe and return top interests by confidence
220
+ return dedupeByConfidence(interests).slice(0, 5);
221
+ }
222
+
223
+ /**
224
+ * Infer interests from mood/state
225
+ */
226
+ function inferFromMood(mood) {
227
+ const interests = [];
228
+ const moodLower = (mood || '').toLowerCase();
229
+
230
+ if (moodLower.includes('shipping') || moodLower.includes('deploy')) {
231
+ interests.push({
232
+ interest: 'shipping',
233
+ source: 'mood',
234
+ confidence: 0.6,
235
+ detail: 'actively shipping'
236
+ });
237
+ }
238
+
239
+ if (moodLower.includes('debug') || moodLower.includes('fix')) {
240
+ interests.push({
241
+ interest: 'debugging',
242
+ source: 'mood',
243
+ confidence: 0.6,
244
+ detail: 'debugging'
245
+ });
246
+ }
247
+
248
+ return interests;
249
+ }
250
+
251
+ /**
252
+ * Infer interests from error messages
253
+ */
254
+ function inferFromError(error) {
255
+ const interests = [];
256
+ const errorLower = (error || '').toLowerCase();
257
+
258
+ // Database errors
259
+ if (/sql|postgres|mysql|mongo|prisma|database/i.test(errorLower)) {
260
+ interests.push({
261
+ interest: 'database',
262
+ source: 'error',
263
+ confidence: 0.5,
264
+ detail: 'debugging database issue'
265
+ });
266
+ }
267
+
268
+ // API errors
269
+ if (/fetch|axios|api|endpoint|401|403|404|500/i.test(errorLower)) {
270
+ interests.push({
271
+ interest: 'api',
272
+ source: 'error',
273
+ confidence: 0.5,
274
+ detail: 'debugging API issue'
275
+ });
276
+ }
277
+
278
+ // Type errors (TypeScript)
279
+ if (/type.*error|typescript|cannot assign/i.test(errorLower)) {
280
+ interests.push({
281
+ interest: 'typescript',
282
+ source: 'error',
283
+ confidence: 0.5,
284
+ detail: 'debugging type error'
285
+ });
286
+ }
287
+
288
+ return interests;
289
+ }
290
+
291
+ /**
292
+ * Get file extension from path
293
+ */
294
+ function getFileExtension(filePath) {
295
+ if (!filePath) return null;
296
+ const match = filePath.match(/\.[^./]+$/);
297
+ return match ? match[0].toLowerCase() : null;
298
+ }
299
+
300
+ /**
301
+ * Deduplicate interests, keeping highest confidence for each
302
+ */
303
+ function dedupeByConfidence(interests) {
304
+ const byInterest = new Map();
305
+
306
+ for (const item of interests) {
307
+ const existing = byInterest.get(item.interest);
308
+ if (!existing || item.confidence > existing.confidence) {
309
+ byInterest.set(item.interest, item);
310
+ }
311
+ }
312
+
313
+ return Array.from(byInterest.values())
314
+ .sort((a, b) => b.confidence - a.confidence);
315
+ }
316
+
317
+ /**
318
+ * Merge live interests with static interests
319
+ * Prioritizes explicit user-set interests, then adds live context
320
+ *
321
+ * @param {Array} staticInterests - User's profile interests
322
+ * @param {Array} liveInterests - Inferred from current context
323
+ * @returns {Array} Combined interests with sources
324
+ */
325
+ function mergeInterests(staticInterests = [], liveInterests = []) {
326
+ const merged = new Map();
327
+
328
+ // Add static interests first (higher priority)
329
+ for (const interest of staticInterests) {
330
+ merged.set(interest, {
331
+ interest,
332
+ source: 'profile',
333
+ confidence: 1.0
334
+ });
335
+ }
336
+
337
+ // Add live interests if not already present
338
+ for (const item of liveInterests) {
339
+ if (!merged.has(item.interest)) {
340
+ merged.set(item.interest, item);
341
+ }
342
+ }
343
+
344
+ return Array.from(merged.values())
345
+ .sort((a, b) => b.confidence - a.confidence);
346
+ }
347
+
348
+ /**
349
+ * Format live interests for display
350
+ */
351
+ function formatLiveInterests(interests) {
352
+ if (!interests || interests.length === 0) return null;
353
+
354
+ const live = interests.filter(i => i.source !== 'profile');
355
+ if (live.length === 0) return null;
356
+
357
+ return live.map(i => i.interest).join(', ');
358
+ }
359
+
360
+ module.exports = {
361
+ inferLiveInterests,
362
+ mergeInterests,
363
+ formatLiveInterests,
364
+ dedupeByConfidence,
365
+ // Expose mappings for testing/extension
366
+ MODULE_TO_INTERESTS,
367
+ TOPIC_TO_INTERESTS,
368
+ EXTENSION_TO_INTERESTS
369
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * MCP `list_changed` notification emitter
3
+ *
4
+ * Triggers Claude to refresh tool results without reconnection.
5
+ * Implements debouncing to prevent notification spam.
6
+ *
7
+ * This eliminates the need for 30-second polling loops,
8
+ * reducing API calls by ~90% and providing instant updates.
9
+ */
10
+
11
+ class NotificationEmitter {
12
+ constructor(server) {
13
+ this.server = server;
14
+ this.debounceTimers = {};
15
+ }
16
+
17
+ /**
18
+ * Emit list_changed notification with debouncing
19
+ * @param {string} reason - Why notification is being sent (for logging/debugging)
20
+ * @param {number} debounceMs - Debounce window in milliseconds (default: 1000ms)
21
+ */
22
+ emitChange(reason, debounceMs = 1000) {
23
+ // Debounce to prevent notification spam
24
+ // If we get multiple changes of the same type within the window,
25
+ // only emit one notification
26
+ if (this.debounceTimers[reason]) {
27
+ clearTimeout(this.debounceTimers[reason]);
28
+ }
29
+
30
+ this.debounceTimers[reason] = setTimeout(() => {
31
+ try {
32
+ this.server.notification({
33
+ method: "notifications/list_changed"
34
+ });
35
+ delete this.debounceTimers[reason];
36
+ } catch (e) {
37
+ // Silent fail - notifications are best-effort
38
+ // If notification fails, Claude will continue working normally
39
+ }
40
+ }, debounceMs);
41
+ }
42
+
43
+ /**
44
+ * Emit immediately without debouncing
45
+ * Use for urgent updates like direct mentions
46
+ */
47
+ emitImmediate() {
48
+ try {
49
+ this.server.notification({
50
+ method: "notifications/list_changed"
51
+ });
52
+ } catch (e) {
53
+ // Silent fail
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Cancel pending notifications for a specific reason
59
+ * Useful when shutting down or cleaning up
60
+ */
61
+ cancel(reason) {
62
+ if (this.debounceTimers[reason]) {
63
+ clearTimeout(this.debounceTimers[reason]);
64
+ delete this.debounceTimers[reason];
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Cancel all pending notifications
70
+ */
71
+ cancelAll() {
72
+ Object.values(this.debounceTimers).forEach(timer => clearTimeout(timer));
73
+ this.debounceTimers = {};
74
+ }
75
+ }
76
+
77
+ module.exports = NotificationEmitter;
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "slashvibe-mcp",
3
- "version": "0.2.3",
4
- "mcpName": "io.github.brightseth/vibe",
3
+ "version": "0.2.4",
4
+ "mcpName": "io.github.vibecodinginc/vibe",
5
5
  "description": "Social layer for Claude Code - DMs, presence, and connection between AI-assisted developers",
6
6
  "main": "index.js",
7
7
  "bin": {
8
8
  "slashvibe-mcp": "./index.js"
9
9
  },
10
10
  "scripts": {
11
- "start": "node index.js"
11
+ "start": "node index.js",
12
+ "test": "node --test tools/*.test.js",
13
+ "test:context": "node --test tools/_work-context.test.js"
12
14
  },
13
15
  "keywords": [
14
16
  "mcp",
@@ -39,6 +41,8 @@
39
41
  "discord.js",
40
42
  "memory.js",
41
43
  "notify.js",
44
+ "notification-emitter.js",
45
+ "analytics.js",
42
46
  "presence.js",
43
47
  "prompts.js",
44
48
  "twitter.js",
package/store/api.js CHANGED
@@ -10,12 +10,30 @@ const https = require('https');
10
10
  const http = require('http');
11
11
  const config = require('../config');
12
12
  const crypto = require('../crypto');
13
+ const authStore = require('../auth-store');
13
14
 
14
15
  const API_URL = process.env.VIBE_API_URL || 'https://www.slashvibe.dev';
15
16
 
16
17
  // Default timeout for API requests (10 seconds)
17
18
  const REQUEST_TIMEOUT = 10000;
18
19
 
20
+ /**
21
+ * Force reload of config module to pick up auth changes
22
+ * This clears the require cache and reloads the config from disk
23
+ */
24
+ function reloadConfig() {
25
+ try {
26
+ // Clear the config module from require cache
27
+ const configPath = require.resolve('../config');
28
+ delete require.cache[configPath];
29
+ // Re-require to get fresh module
30
+ return require('../config');
31
+ } catch (e) {
32
+ console.error('[api] Failed to reload config:', e.message);
33
+ return config;
34
+ }
35
+ }
36
+
19
37
  function request(method, path, data = null, options = {}) {
20
38
  return new Promise((resolve, reject) => {
21
39
  const url = new URL(path, API_URL);
@@ -28,8 +46,9 @@ function request(method, path, data = null, options = {}) {
28
46
  'User-Agent': 'vibe-mcp/1.0'
29
47
  };
30
48
 
31
- // Add auth token if provided or if we have one stored
32
- const token = options.token || config.getAuthToken();
49
+ // Add auth token: priority is explicit option > in-memory store > config file
50
+ // authStore is the SOURCE OF TRUTH during runtime (immediate updates from OAuth)
51
+ const token = options.token || authStore.getToken() || config.getAuthToken();
33
52
  if (token && options.auth !== false) {
34
53
  headers['Authorization'] = `Bearer ${token}`;
35
54
  }
@@ -46,9 +65,45 @@ function request(method, path, data = null, options = {}) {
46
65
  const req = client.request(reqOptions, (res) => {
47
66
  let body = '';
48
67
  res.on('data', chunk => body += chunk);
49
- res.on('end', () => {
68
+ res.on('end', async () => {
50
69
  // Handle non-2xx responses
51
70
  if (res.statusCode >= 400) {
71
+ // 401 REFRESH: If unauthorized and haven't retried, try to recover
72
+ if (res.statusCode === 401 && !options._retried && options.auth !== false) {
73
+ console.error('[api] 401 received, attempting token refresh...');
74
+
75
+ // First, reload config from disk (in case token was saved but not pushed to store)
76
+ const freshConfig = reloadConfig();
77
+ const diskToken = freshConfig.getAuthToken();
78
+
79
+ // If disk has a different token, sync it to the auth store
80
+ if (diskToken && diskToken !== authStore.getToken()) {
81
+ console.error('[api] Found newer token on disk, syncing to auth store...');
82
+ authStore.setToken(diskToken);
83
+ }
84
+
85
+ // Now check if we have a fresh token to retry with
86
+ const freshToken = authStore.getToken() || diskToken;
87
+
88
+ // Only retry if we got a different/new token
89
+ if (freshToken && freshToken !== token) {
90
+ console.error('[api] Found fresh token, retrying request...');
91
+ try {
92
+ const retryResult = await request(method, path, data, {
93
+ ...options,
94
+ token: freshToken,
95
+ _retried: true
96
+ });
97
+ resolve(retryResult);
98
+ return;
99
+ } catch (retryError) {
100
+ console.error('[api] Retry failed:', retryError.message);
101
+ }
102
+ } else {
103
+ console.error('[api] No fresh token found, not retrying');
104
+ }
105
+ }
106
+
52
107
  try {
53
108
  const parsed = JSON.parse(body);
54
109
  resolve({ success: false, error: parsed.error || `HTTP ${res.statusCode}`, statusCode: res.statusCode });
@@ -198,7 +253,9 @@ async function getActiveUsers() {
198
253
  const result = await request('GET', '/api/presence');
199
254
  // Combine active and away users
200
255
  const users = [...(result.active || []), ...(result.away || [])];
201
- return users.map(u => ({
256
+
257
+ // Map to normalized format
258
+ const mappedUsers = users.map(u => ({
202
259
  handle: u.username,
203
260
  one_liner: u.workingOn,
204
261
  lastSeen: new Date(u.lastSeen).getTime(),
@@ -219,6 +276,24 @@ async function getActiveUsers() {
219
276
  awayMessage: u.context?.awayMessage || null,
220
277
  awayAt: u.context?.awayAt || null
221
278
  }));
279
+
280
+ // Sync presence data to local profiles (non-blocking)
281
+ // This enables discovery to find users by what they're building
282
+ try {
283
+ const profiles = require('./profiles');
284
+ profiles.syncFromPresence(mappedUsers).then(synced => {
285
+ if (synced > 0) {
286
+ // Auto-infer interests for new/updated profiles
287
+ profiles.inferMissingInterests().catch(e =>
288
+ console.error('[presence] interest inference failed:', e.message)
289
+ );
290
+ }
291
+ }).catch(e => console.error('[presence] sync failed:', e.message));
292
+ } catch (e) {
293
+ // Non-fatal: profiles module may not be available in some contexts
294
+ }
295
+
296
+ return mappedUsers;
222
297
  } catch (e) {
223
298
  console.error('Who failed:', e.message);
224
299
  return [];
@@ -313,6 +388,12 @@ async function getInbox(handle) {
313
388
  // Use unified messages endpoint - returns { inbox, unread, bySender }
314
389
  const result = await request('GET', `/api/messages?user=${handle}`);
315
390
 
391
+ // Check for API errors (auth failures, etc.)
392
+ if (result.success === false) {
393
+ console.error('[getInbox] API error:', result.error, result.message);
394
+ return [];
395
+ }
396
+
316
397
  // Group messages by sender into thread format
317
398
  const bySender = result.bySender || {};
318
399
  return Object.entries(bySender).map(([sender, messages]) => ({
@@ -657,6 +738,29 @@ async function getArtifact(slug) {
657
738
  }
658
739
  }
659
740
 
741
+ /**
742
+ * Update an artifact by ID or slug
743
+ * @param {string} idOrSlug - Artifact ID or slug
744
+ * @param {Object} artifact - Updated artifact data
745
+ */
746
+ async function updateArtifact(idOrSlug, artifact) {
747
+ try {
748
+ const result = await request('PUT', `/api/artifacts/${idOrSlug}`, artifact);
749
+
750
+ if (result.success === false) {
751
+ return { success: false, error: result.error || 'Update failed' };
752
+ }
753
+
754
+ return {
755
+ success: true,
756
+ artifact: result.artifact
757
+ };
758
+ } catch (e) {
759
+ console.error('Update artifact failed:', e.message);
760
+ return { success: false, error: e.message };
761
+ }
762
+ }
763
+
660
764
  /**
661
765
  * List artifacts
662
766
  * @param {Object} options - { scope: 'mine'|'for-me'|'network', handle, limit }
@@ -751,6 +855,7 @@ module.exports = {
751
855
  // Artifacts
752
856
  createArtifact,
753
857
  getArtifact,
858
+ updateArtifact,
754
859
  listArtifacts,
755
860
  sendArtifactCard,
756
861