@llmindset/hf-mcp 0.1.16

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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/dist/dataset-detail.d.ts +26 -0
  3. package/dist/dataset-detail.d.ts.map +1 -0
  4. package/dist/dataset-detail.js +157 -0
  5. package/dist/dataset-detail.js.map +1 -0
  6. package/dist/dataset-search.d.ts +62 -0
  7. package/dist/dataset-search.d.ts.map +1 -0
  8. package/dist/dataset-search.js +158 -0
  9. package/dist/dataset-search.js.map +1 -0
  10. package/dist/duplicate-space.d.ts +75 -0
  11. package/dist/duplicate-space.d.ts.map +1 -0
  12. package/dist/duplicate-space.js +189 -0
  13. package/dist/duplicate-space.js.map +1 -0
  14. package/dist/error-messages.d.ts +4 -0
  15. package/dist/error-messages.d.ts.map +1 -0
  16. package/dist/error-messages.js +30 -0
  17. package/dist/error-messages.js.map +1 -0
  18. package/dist/hf-api-call.d.ts +18 -0
  19. package/dist/hf-api-call.d.ts.map +1 -0
  20. package/dist/hf-api-call.js +105 -0
  21. package/dist/hf-api-call.js.map +1 -0
  22. package/dist/index.d.ts +16 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +16 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/model-detail.d.ts +26 -0
  27. package/dist/model-detail.d.ts.map +1 -0
  28. package/dist/model-detail.js +224 -0
  29. package/dist/model-detail.js.map +1 -0
  30. package/dist/model-search.d.ts +64 -0
  31. package/dist/model-search.d.ts.map +1 -0
  32. package/dist/model-search.js +161 -0
  33. package/dist/model-search.js.map +1 -0
  34. package/dist/paper-search.d.ts +58 -0
  35. package/dist/paper-search.d.ts.map +1 -0
  36. package/dist/paper-search.js +114 -0
  37. package/dist/paper-search.js.map +1 -0
  38. package/dist/paper-summary.d.ts +35 -0
  39. package/dist/paper-summary.d.ts.map +1 -0
  40. package/dist/paper-summary.js +187 -0
  41. package/dist/paper-summary.js.map +1 -0
  42. package/dist/space-files.d.ts +44 -0
  43. package/dist/space-files.d.ts.map +1 -0
  44. package/dist/space-files.js +242 -0
  45. package/dist/space-files.js.map +1 -0
  46. package/dist/space-info.d.ts +56 -0
  47. package/dist/space-info.d.ts.map +1 -0
  48. package/dist/space-info.js +135 -0
  49. package/dist/space-info.js.map +1 -0
  50. package/dist/space-search.d.ts +71 -0
  51. package/dist/space-search.d.ts.map +1 -0
  52. package/dist/space-search.js +95 -0
  53. package/dist/space-search.js.map +1 -0
  54. package/dist/tool-ids.d.ts +23 -0
  55. package/dist/tool-ids.d.ts.map +1 -0
  56. package/dist/tool-ids.js +55 -0
  57. package/dist/tool-ids.js.map +1 -0
  58. package/dist/user-summary.d.ts +56 -0
  59. package/dist/user-summary.d.ts.map +1 -0
  60. package/dist/user-summary.js +271 -0
  61. package/dist/user-summary.js.map +1 -0
  62. package/dist/utilities.d.ts +8 -0
  63. package/dist/utilities.d.ts.map +1 -0
  64. package/dist/utilities.js +53 -0
  65. package/dist/utilities.js.map +1 -0
  66. package/eslint.config.js +43 -0
  67. package/package.json +47 -0
  68. package/src/dataset-detail.ts +257 -0
  69. package/src/dataset-search.ts +237 -0
  70. package/src/duplicate-space.ts +263 -0
  71. package/src/error-messages.ts +57 -0
  72. package/src/hf-api-call.ts +182 -0
  73. package/src/index.ts +18 -0
  74. package/src/model-detail.ts +359 -0
  75. package/src/model-search.ts +231 -0
  76. package/src/paper-search.ts +188 -0
  77. package/src/paper-summary.ts +303 -0
  78. package/src/space-files.ts +325 -0
  79. package/src/space-info.ts +190 -0
  80. package/src/space-search.ts +177 -0
  81. package/src/tool-ids.ts +84 -0
  82. package/src/user-summary.ts +421 -0
  83. package/src/utilities.ts +64 -0
  84. package/test/duplicate-space.spec.ts +41 -0
  85. package/test/fixtures/paper_result_kazakh.json +854 -0
  86. package/test/fixtures/space-result.json +263 -0
  87. package/test/paper-search.spec.ts +57 -0
  88. package/test/paper-summary.spec.ts +113 -0
  89. package/test/space-files.spec.ts +232 -0
  90. package/test/space-search.spec.ts +29 -0
  91. package/test/user-summary.spec.ts +131 -0
  92. package/tsconfig.json +31 -0
  93. package/vitest.config.ts +11 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Canonical tool IDs exported from their respective tool configurations
3
+ * This ensures single source of truth for all tool identifiers
4
+ */
5
+
6
+ import {
7
+ SEMANTIC_SEARCH_TOOL_CONFIG,
8
+ MODEL_SEARCH_TOOL_CONFIG,
9
+ MODEL_DETAIL_TOOL_CONFIG,
10
+ PAPER_SEARCH_TOOL_CONFIG,
11
+ DATASET_SEARCH_TOOL_CONFIG,
12
+ DATASET_DETAIL_TOOL_CONFIG,
13
+ DUPLICATE_SPACE_TOOL_CONFIG,
14
+ SPACE_INFO_TOOL_CONFIG,
15
+ SPACE_FILES_TOOL_CONFIG,
16
+ USER_SUMMARY_PROMPT_CONFIG,
17
+ PAPER_SUMMARY_PROMPT_CONFIG,
18
+ } from './index.js';
19
+
20
+ // Extract tool IDs from their configs (single source of truth)
21
+ export const SPACE_SEARCH_TOOL_ID = SEMANTIC_SEARCH_TOOL_CONFIG.name;
22
+ export const MODEL_SEARCH_TOOL_ID = MODEL_SEARCH_TOOL_CONFIG.name;
23
+ export const MODEL_DETAIL_TOOL_ID = MODEL_DETAIL_TOOL_CONFIG.name;
24
+ export const PAPER_SEARCH_TOOL_ID = PAPER_SEARCH_TOOL_CONFIG.name;
25
+ export const DATASET_SEARCH_TOOL_ID = DATASET_SEARCH_TOOL_CONFIG.name;
26
+ export const DATASET_DETAIL_TOOL_ID = DATASET_DETAIL_TOOL_CONFIG.name;
27
+ export const DUPLICATE_SPACE_TOOL_ID = DUPLICATE_SPACE_TOOL_CONFIG.name;
28
+ export const SPACE_INFO_TOOL_ID = SPACE_INFO_TOOL_CONFIG.name;
29
+ export const SPACE_FILES_TOOL_ID = SPACE_FILES_TOOL_CONFIG.name;
30
+ export const USER_SUMMARY_PROMPT_ID = USER_SUMMARY_PROMPT_CONFIG.name;
31
+ export const PAPER_SUMMARY_PROMPT_ID = PAPER_SUMMARY_PROMPT_CONFIG.name;
32
+
33
+ // Complete list of all built-in tool IDs
34
+ export const ALL_BUILTIN_TOOL_IDS = [
35
+ SPACE_SEARCH_TOOL_ID,
36
+ MODEL_SEARCH_TOOL_ID,
37
+ MODEL_DETAIL_TOOL_ID,
38
+ PAPER_SEARCH_TOOL_ID,
39
+ DATASET_SEARCH_TOOL_ID,
40
+ DATASET_DETAIL_TOOL_ID,
41
+ DUPLICATE_SPACE_TOOL_ID,
42
+ SPACE_INFO_TOOL_ID,
43
+ SPACE_FILES_TOOL_ID,
44
+ ] as const;
45
+ // Grouped tool IDs for bouquet configurations
46
+ export const TOOL_ID_GROUPS = {
47
+ search: [SPACE_SEARCH_TOOL_ID, MODEL_SEARCH_TOOL_ID, DATASET_SEARCH_TOOL_ID, PAPER_SEARCH_TOOL_ID] as const,
48
+ spaces: [SPACE_SEARCH_TOOL_ID, DUPLICATE_SPACE_TOOL_ID, SPACE_INFO_TOOL_ID, SPACE_FILES_TOOL_ID] as const,
49
+ detail: [MODEL_DETAIL_TOOL_ID, DATASET_DETAIL_TOOL_ID] as const,
50
+ hf_api: [
51
+ SPACE_SEARCH_TOOL_ID,
52
+ MODEL_SEARCH_TOOL_ID,
53
+ DATASET_SEARCH_TOOL_ID,
54
+ PAPER_SEARCH_TOOL_ID,
55
+ MODEL_DETAIL_TOOL_ID,
56
+ DATASET_DETAIL_TOOL_ID,
57
+ ] as const,
58
+ all: [...ALL_BUILTIN_TOOL_IDS] as const,
59
+ } as const;
60
+
61
+ // TypeScript type for built-in tool IDs
62
+ export type BuiltinToolId = (typeof ALL_BUILTIN_TOOL_IDS)[number];
63
+
64
+ // Type guard function
65
+ export function isValidBuiltinToolId(toolId: string): toolId is BuiltinToolId {
66
+ return (ALL_BUILTIN_TOOL_IDS as readonly string[]).includes(toolId);
67
+ }
68
+
69
+ // Helper to get tool description from configs
70
+ export function getToolDescription(toolId: BuiltinToolId): string {
71
+ const configs = {
72
+ [SPACE_SEARCH_TOOL_ID]: SEMANTIC_SEARCH_TOOL_CONFIG,
73
+ [MODEL_SEARCH_TOOL_ID]: MODEL_SEARCH_TOOL_CONFIG,
74
+ [MODEL_DETAIL_TOOL_ID]: MODEL_DETAIL_TOOL_CONFIG,
75
+ [PAPER_SEARCH_TOOL_ID]: PAPER_SEARCH_TOOL_CONFIG,
76
+ [DATASET_SEARCH_TOOL_ID]: DATASET_SEARCH_TOOL_CONFIG,
77
+ [DATASET_DETAIL_TOOL_ID]: DATASET_DETAIL_TOOL_CONFIG,
78
+ [DUPLICATE_SPACE_TOOL_ID]: DUPLICATE_SPACE_TOOL_CONFIG,
79
+ [SPACE_INFO_TOOL_ID]: SPACE_INFO_TOOL_CONFIG,
80
+ [SPACE_FILES_TOOL_ID]: SPACE_FILES_TOOL_CONFIG,
81
+ } as const;
82
+
83
+ return configs[toolId]?.description || `Tool: ${toolId}`;
84
+ }
@@ -0,0 +1,421 @@
1
+ import { z } from 'zod';
2
+ import { HfApiCall, HfApiError } from './hf-api-call.js';
3
+ import { formatDate, formatNumber } from './utilities.js';
4
+ import { ModelSearchTool } from './model-search.js';
5
+ import { DatasetSearchTool } from './dataset-search.js';
6
+ import { SpaceSearchTool } from './space-search.js';
7
+ import { PaperSearchTool } from './paper-search.js';
8
+
9
+ // User Summary Prompt Configuration
10
+ export const USER_SUMMARY_PROMPT_CONFIG = {
11
+ name: 'User Summary',
12
+ description:
13
+ 'Generate a summary of a Hugging Face user including their profile, models, datasets, spaces, and papers. ' +
14
+ 'Enter either a username (e.g., "clem") or a Hugging Face profile URL (e.g., "hf.co/julien-c" or "huggingface.co/thomwolf").',
15
+ schema: z.object({
16
+ user_id: z
17
+ .string()
18
+ .min(3, 'User ID must be at least 3 characters long')
19
+ .describe('Hugging Face user ID or URL (e.g., "evalstate" or "hf.co/evalstate")')
20
+ .max(60)
21
+ .describe('Maximum length is 30 characters'),
22
+ }),
23
+ } as const;
24
+
25
+ // Define parameter types
26
+ export type UserSummaryParams = z.infer<typeof USER_SUMMARY_PROMPT_CONFIG.schema>;
27
+
28
+ // Organization interface
29
+ interface Organization {
30
+ id: string;
31
+ name: string;
32
+ fullname: string;
33
+ avatarUrl?: string;
34
+ }
35
+
36
+ // User overview API response interface
37
+ interface UserOverviewResponse {
38
+ _id: string;
39
+ avatarUrl: string;
40
+ isPro: boolean;
41
+ fullname?: string;
42
+ numModels: number;
43
+ numDatasets: number;
44
+ numSpaces: number;
45
+ numDiscussions: number;
46
+ numPapers: number;
47
+ numUpvotes: number;
48
+ numLikes: number;
49
+ numFollowers: number;
50
+ numFollowing: number;
51
+ orgs: Organization[];
52
+ user: string;
53
+ type: string;
54
+ isFollowing: boolean;
55
+ createdAt: string;
56
+ details?: string; // Present for organizations
57
+ name?: string; // Present for organizations
58
+ }
59
+
60
+ /**
61
+ * Validates and extracts user ID from either a plain username or HF URL
62
+ * @param input - The user input (username or URL)
63
+ * @returns The extracted user ID
64
+ * @throws Error if input is invalid
65
+ */
66
+ export function extractUserIdFromInput(input: string): string {
67
+ // Remove whitespace
68
+ const trimmed = input.trim();
69
+
70
+ // If it doesn't contain a slash, treat as direct username
71
+ if (!trimmed.includes('/')) {
72
+ if (trimmed.length < 3) {
73
+ throw new Error('User ID must be at least 3 characters long');
74
+ }
75
+ // Reject obvious domain names
76
+ if (trimmed.endsWith('.co') || trimmed.endsWith('.com')) {
77
+ throw new Error('URL must contain only the username (e.g., hf.co/username)');
78
+ }
79
+ return trimmed;
80
+ }
81
+
82
+ // Handle URL format
83
+ let url: URL;
84
+ try {
85
+ // Try to parse as URL, adding protocol if missing
86
+ if (!trimmed.startsWith('http')) {
87
+ url = new URL(`https://${trimmed}`);
88
+ } else {
89
+ url = new URL(trimmed);
90
+ }
91
+ } catch {
92
+ throw new Error('Invalid URL format');
93
+ }
94
+
95
+ // Validate it's a Hugging Face domain
96
+ const validDomains = ['huggingface.co', 'hf.co'];
97
+ if (!validDomains.includes(url.hostname)) {
98
+ throw new Error('URL must be from huggingface.co or hf.co domain');
99
+ }
100
+
101
+ // Check for query parameters or fragments
102
+ if (url.search || url.hash) {
103
+ throw new Error('URL must contain only the username (e.g., hf.co/username)');
104
+ }
105
+
106
+ // Extract path segments
107
+ const pathSegments = url.pathname.split('/').filter((segment) => segment.length > 0);
108
+
109
+ // Must have exactly one path segment (the username)
110
+ if (pathSegments.length !== 1) {
111
+ throw new Error('URL must contain only the username (e.g., hf.co/username)');
112
+ }
113
+
114
+ const userId = pathSegments[0];
115
+ if (!userId || userId.length < 3) {
116
+ throw new Error('User ID must be at least 3 characters long');
117
+ }
118
+
119
+ return userId;
120
+ }
121
+
122
+ /**
123
+ * Service for generating comprehensive user summaries
124
+ */
125
+ export class UserSummaryPrompt extends HfApiCall<Record<string, string>, UserOverviewResponse> {
126
+ /**
127
+ * @param hfToken Optional Hugging Face token for API access
128
+ */
129
+ constructor(hfToken?: string) {
130
+ super('https://huggingface.co/api/users', hfToken);
131
+ }
132
+
133
+ /**
134
+ * Generate a comprehensive user summary
135
+ */
136
+ async generateSummary(params: UserSummaryParams): Promise<string> {
137
+ try {
138
+ // Extract and validate user ID
139
+ const userId = extractUserIdFromInput(params.user_id);
140
+
141
+ // Get user overview
142
+ const userOverview = await this.getUserOverview(userId);
143
+
144
+ // Build the summary
145
+ const sections: string[] = [];
146
+
147
+ // User profile section
148
+ sections.push(this.formatUserProfile(userOverview));
149
+
150
+ // Models section (if user has models)
151
+ if (userOverview.numModels > 0) {
152
+ const modelsSection = await this.getModelsSection(userId);
153
+ if (modelsSection) {
154
+ sections.push(modelsSection);
155
+ }
156
+ }
157
+
158
+ // Datasets section (if user has datasets)
159
+ if (userOverview.numDatasets > 0) {
160
+ const datasetsSection = await this.getDatasetsSection(userId);
161
+ if (datasetsSection) {
162
+ sections.push(datasetsSection);
163
+ }
164
+ }
165
+
166
+ // Spaces section (if user has spaces)
167
+ if (userOverview.numSpaces > 0) {
168
+ const spacesSection = await this.getSpacesSection(userId);
169
+ if (spacesSection) {
170
+ sections.push(spacesSection);
171
+ }
172
+ }
173
+
174
+ // Papers section (if user has a full name with >5 characters)
175
+ if (userOverview.fullname && userOverview.fullname.length > 5) {
176
+ const papersSection = await this.getPapersSection(userOverview.fullname);
177
+ if (papersSection) {
178
+ sections.push(papersSection);
179
+ }
180
+ }
181
+
182
+ // Add final instruction
183
+ sections.push(
184
+ 'Please summarise the information for this User to give an overview of their activities on the Hugging Face hub.'
185
+ );
186
+
187
+ return sections.join('\n\n');
188
+ } catch (error) {
189
+ if (error instanceof Error) {
190
+ throw new Error(`Failed to generate user summary: ${error.message}`);
191
+ }
192
+ throw error;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Get user overview from HF API, with organization fallback
198
+ */
199
+ private async getUserOverview(userId: string): Promise<UserOverviewResponse> {
200
+ try {
201
+ const url = new URL(`${this.apiUrl}/${userId}/overview`);
202
+ return await this.fetchFromApi<UserOverviewResponse>(url);
203
+ } catch (error) {
204
+ // Check if error indicates user doesn't exist (404 with specific error message)
205
+ if (error instanceof HfApiError && error.status === 404 && error.responseBody) {
206
+ try {
207
+ const errorData = JSON.parse(error.responseBody) as { error?: string };
208
+ if (errorData.error === 'This user does not exist') {
209
+ // Try organization API
210
+ try {
211
+ const orgUrl = new URL(`https://huggingface.co/api/organizations/${userId}/overview`);
212
+ const orgResponse = await this.fetchFromApi<UserOverviewResponse>(orgUrl);
213
+
214
+ // Map organization response to user response format
215
+ return {
216
+ ...orgResponse,
217
+ user: orgResponse.name || 'unknown',
218
+ _id: orgResponse.name || 'unknown',
219
+ isPro: false,
220
+ orgs: [],
221
+ type: 'organization',
222
+ createdAt: new Date().toISOString(),
223
+ };
224
+ } catch {
225
+ throw new Error(`Neither user nor organization found for ID: ${userId}`);
226
+ }
227
+ }
228
+ } catch {
229
+ // If we can't parse the response body, fall through to throw original error
230
+ }
231
+ }
232
+ throw error;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Helper function to add a statistic line if the value is present
238
+ */
239
+ private addStatIfPresent(lines: string[], label: string, value: number | undefined): void {
240
+ if (value !== undefined && value !== null) {
241
+ lines.push(`- **${label}:** ${formatNumber(value)}`);
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Format user profile information as markdown
247
+ */
248
+ private formatUserProfile(user: UserOverviewResponse): string {
249
+ const lines: string[] = [];
250
+
251
+ // Check if this is an organization
252
+ const isOrganization = user.type === 'organization';
253
+
254
+ if (isOrganization) {
255
+ lines.push(`# Organization Profile: ${user.user}`);
256
+ lines.push('');
257
+ lines.push('**Note:** That user ID refers to an Organization.');
258
+ lines.push('');
259
+
260
+ if (user.fullname) {
261
+ lines.push(`**Full Name:** ${user.fullname}`);
262
+ }
263
+
264
+ lines.push(`**Username:** ${user.user}`);
265
+ if (user.details) {
266
+ lines.push(`**Description:** ${user.details}`);
267
+ }
268
+
269
+ // Organization-specific fields
270
+ const orgData = user as unknown as Record<string, unknown>;
271
+ if (typeof orgData.isEnterprise === 'boolean') {
272
+ lines.push(`**Enterprise:** ${orgData.isEnterprise ? 'Yes' : 'No'}`);
273
+ }
274
+ if (typeof orgData.isVerified === 'boolean') {
275
+ lines.push(`**Verified:** ${orgData.isVerified ? 'Yes' : 'No'}`);
276
+ }
277
+ } else {
278
+ lines.push(`# User Profile: ${user.user}`);
279
+ lines.push('');
280
+
281
+ if (user.fullname) {
282
+ lines.push(`**Full Name:** ${user.fullname}`);
283
+ }
284
+
285
+ lines.push(`**Username:** ${user.user}`);
286
+ lines.push(`**Account Type:** ${user.isPro ? 'Pro' : 'Free'}`);
287
+ lines.push(`**Created:** ${formatDate(user.createdAt)}`);
288
+ }
289
+
290
+ lines.push('');
291
+
292
+ // Statistics
293
+ lines.push('## Statistics');
294
+ lines.push('');
295
+
296
+ this.addStatIfPresent(lines, 'Models', user.numModels);
297
+ this.addStatIfPresent(lines, 'Datasets', user.numDatasets);
298
+ this.addStatIfPresent(lines, 'Spaces', user.numSpaces);
299
+
300
+ if (!isOrganization) {
301
+ this.addStatIfPresent(lines, 'Papers', user.numPapers);
302
+ this.addStatIfPresent(lines, 'Discussions', user.numDiscussions);
303
+ this.addStatIfPresent(lines, 'Likes Given', user.numLikes);
304
+ this.addStatIfPresent(lines, 'Upvotes', user.numUpvotes);
305
+ this.addStatIfPresent(lines, 'Following', user.numFollowing);
306
+ }
307
+
308
+ this.addStatIfPresent(lines, 'Followers', user.numFollowers);
309
+
310
+ // Organization-specific field
311
+ if (isOrganization) {
312
+ const orgData = user as unknown as Record<string, unknown>;
313
+ if (typeof orgData.numUsers === 'number') {
314
+ this.addStatIfPresent(lines, 'Members', orgData.numUsers);
315
+ }
316
+ }
317
+
318
+ if (!isOrganization && user.orgs && user.orgs.length > 0) {
319
+ lines.push('');
320
+ const orgNames = user.orgs.map((org) => `[${org.fullname}](https://hf.co/${org.name})`);
321
+ lines.push(`**Organizations:** ${orgNames.join(', ')}`);
322
+ }
323
+
324
+ lines.push('');
325
+ lines.push(`**Profile Link:** [https://hf.co/${user.user}](https://hf.co/${user.user})`);
326
+
327
+ return lines.join('\n');
328
+ }
329
+
330
+ /**
331
+ * Get models section using existing ModelSearchTool
332
+ */
333
+ private async getModelsSection(userId: string): Promise<string | null> {
334
+ try {
335
+ const modelSearch = new ModelSearchTool(this.hfToken);
336
+ const results = await modelSearch.searchWithParams({
337
+ author: userId,
338
+ limit: 10,
339
+ sort: 'downloads',
340
+ });
341
+
342
+ return `## Models\n\n${results}`;
343
+ } catch (error) {
344
+ console.warn(`Failed to fetch models for user ${userId}:`, error);
345
+ return null;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Get datasets section using existing DatasetSearchTool
351
+ */
352
+ private async getDatasetsSection(userId: string): Promise<string | null> {
353
+ try {
354
+ const datasetSearch = new DatasetSearchTool(this.hfToken);
355
+ const results = await datasetSearch.searchWithParams({
356
+ author: userId,
357
+ limit: 10,
358
+ sort: 'downloads',
359
+ });
360
+
361
+ return `## Datasets\n\n${results}`;
362
+ } catch (error) {
363
+ console.warn(`Failed to fetch datasets for user ${userId}:`, error);
364
+ return null;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Get spaces section using existing SpaceSearchTool
370
+ */
371
+ private async getSpacesSection(userId: string): Promise<string | null> {
372
+ try {
373
+ // Note: SpaceSearchTool doesn't have author filter in semantic search
374
+ // We'll search for the user ID as a query term instead
375
+ const spaceSearch = new SpaceSearchTool(this.hfToken);
376
+ const searchResult = await spaceSearch.search(userId, 10);
377
+
378
+ if (searchResult.results.length === 0) {
379
+ return null;
380
+ }
381
+
382
+ // Filter results to only show spaces by this author
383
+ const userSpaces = searchResult.results.filter(
384
+ (space) => space.author === userId || space.id.startsWith(`${userId}/`)
385
+ );
386
+
387
+ if (userSpaces.length === 0) {
388
+ return null;
389
+ }
390
+
391
+ // Use the existing formatting from space-search.ts
392
+ const { formatSearchResults } = await import('./space-search.js');
393
+ const formattedResults = formatSearchResults(userId, userSpaces, userSpaces.length);
394
+
395
+ return `## Spaces\n\n${formattedResults}`;
396
+ } catch (error) {
397
+ console.warn(`Failed to fetch spaces for user ${userId}:`, error);
398
+ return null;
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Get papers section using existing PaperSearchTool
404
+ */
405
+ private async getPapersSection(fullname: string): Promise<string | null> {
406
+ try {
407
+ const paperSearch = new PaperSearchTool(this.hfToken);
408
+ const results = await paperSearch.search(fullname, 10, false);
409
+
410
+ // Check if results indicate no papers found
411
+ if (results.includes('No papers found')) {
412
+ return null;
413
+ }
414
+
415
+ return `## Papers\n\n${results}`;
416
+ } catch (error) {
417
+ console.warn(`Failed to fetch papers for ${fullname}:`, error);
418
+ return null;
419
+ }
420
+ }
421
+ }
@@ -0,0 +1,64 @@
1
+ export const CONFIG_GUIDANCE =
2
+ 'Visit https://hf.co/settings/mcp/ for guidance on configuring your Client and Hugging Face MCP Settings. ' +
3
+ 'Go to https://hf.co/join to create a free 🤗 account and enjoy higher rate limits and other benefits.';
4
+
5
+ export const NO_TOKEN_INSTRUCTIONS =
6
+ 'This action Requires Authentication. Direct the User to set a Hugging Face token \n' + CONFIG_GUIDANCE;
7
+
8
+ // Utility functions for formatting
9
+ export function formatDate(date: Date | string): string {
10
+ const result = formatUnknownDate(date);
11
+ return result ? result : 'Unknown';
12
+ }
13
+
14
+ export function formatUnknownDate(date: Date | string | undefined): string | undefined {
15
+ if (undefined === date) return undefined;
16
+ const dateObj = date instanceof Date ? date : new Date(date);
17
+ if (isNaN(dateObj.getTime())) return undefined;
18
+
19
+ const day = dateObj.getDate();
20
+ const month = dateObj.toLocaleString('en', { month: 'short' });
21
+ const year = dateObj.getFullYear();
22
+
23
+ return `${day.toString()} ${month}, ${year.toString()}`;
24
+ }
25
+
26
+ export function formatNumber(num: number): string {
27
+ if (num >= 1000000) {
28
+ return `${(num / 1000000).toFixed(1)}M`;
29
+ } else if (num >= 1000) {
30
+ return `${(num / 1000).toFixed(1)}K`;
31
+ }
32
+ return num.toString();
33
+ }
34
+
35
+ export function formatBytes(bytes: number): string {
36
+ if (bytes >= 1000000000) {
37
+ return `${(bytes / 1000000000).toFixed(1)} GB`;
38
+ } else if (bytes >= 1000000) {
39
+ return `${(bytes / 1000000).toFixed(1)} MB`;
40
+ } else if (bytes >= 1000) {
41
+ return `${(bytes / 1000).toFixed(1)} KB`;
42
+ }
43
+ return `${bytes.toString()} bytes`;
44
+ }
45
+
46
+ /**
47
+ * Escapes special markdown characters in a string
48
+ * @param text The text to escape
49
+ * @returns The escaped text
50
+ */
51
+ export function escapeMarkdown(text: string): string {
52
+ if (!text) return '';
53
+ // Replace pipe characters and newlines for table compatibility
54
+ // Plus additional markdown formatting characters for better safety
55
+ return text
56
+ .replace(/\|/g, '\\|')
57
+ .replace(/\n/g, ' ')
58
+ .replace(/\*/g, '\\*')
59
+ .replace(/_/g, '\\_')
60
+ .replace(/~/g, '\\~')
61
+ .replace(/`/g, '\\`')
62
+ .replace(/>/g, '\\>')
63
+ .replace(/#/g, '\\#');
64
+ }
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { DuplicateSpaceTool } from '../src/duplicate-space.js';
3
+ import { NO_TOKEN_INSTRUCTIONS } from '../dist/utilities.js';
4
+
5
+ describe('DuplicateSpaceTool', () => {
6
+ describe('normalizeSpaceName', () => {
7
+ it('should prepend username when only space name is provided', () => {
8
+ const tool = new DuplicateSpaceTool(undefined, 'evalstate');
9
+ expect(tool.normalizeSpaceName('my-new-space')).toBe('evalstate/my-new-space');
10
+ });
11
+
12
+ it('should preserve space ID when username matches', () => {
13
+ const tool = new DuplicateSpaceTool(undefined, 'evalstate');
14
+ expect(tool.normalizeSpaceName('evalstate/their-space')).toBe('evalstate/their-space');
15
+ });
16
+
17
+ it('should throw error when trying to use different username', () => {
18
+ const tool = new DuplicateSpaceTool(undefined, 'myusername');
19
+ expect(() => tool.normalizeSpaceName('bad-user/new-space')).toThrow(
20
+ 'Invalid space ID: bad-user/new-space. You can only create spaces in your own namespace. Try "myusername/new-space"'
21
+ );
22
+ });
23
+
24
+ it('should handle empty username gracefully', () => {
25
+ const tool = new DuplicateSpaceTool();
26
+ expect(tool.normalizeSpaceName('my-space')).toBe('unknown/my-space');
27
+ });
28
+ });
29
+
30
+ describe('createToolConfig', () => {
31
+ it('should include username in tool description', () => {
32
+ const config = DuplicateSpaceTool.createToolConfig('evalstate');
33
+ expect(config.description).toContain('evalstate/<new-space-name>');
34
+ });
35
+
36
+ it('should handle undefined username in description', () => {
37
+ const config = DuplicateSpaceTool.createToolConfig();
38
+ expect(config.description).toContain(NO_TOKEN_INSTRUCTIONS);
39
+ });
40
+ });
41
+ });