awess 0.1.0 → 0.3.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "awess",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for searching curated awesome lists and their contents",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -34,7 +34,6 @@
34
34
  "@modelcontextprotocol/sdk": "^1.0.0",
35
35
  "mdast-util-from-markdown": "^2.0.2",
36
36
  "minisearch": "^7.2.0",
37
- "octokit": "^5.0.5",
38
37
  "zod": "^4.3.5"
39
38
  }
40
39
  }
package/src/diff.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { Item, DiffResult } from "./types";
2
+
3
+ export function diffItems(oldItems: Item[], newItems: Item[]): DiffResult {
4
+ const oldByUrl = new Map(oldItems.map(i => [i.url, i]));
5
+ const newByUrl = new Map(newItems.map(i => [i.url, i]));
6
+
7
+ const added: Item[] = [];
8
+ const removed: Item[] = [];
9
+ const unchanged: Item[] = [];
10
+ const updated: Item[] = [];
11
+
12
+ // Check new items against old
13
+ for (const newItem of newItems) {
14
+ const oldItem = oldByUrl.get(newItem.url);
15
+ if (!oldItem) {
16
+ added.push(newItem);
17
+ } else if (oldItem.name === newItem.name && oldItem.description === newItem.description) {
18
+ // Unchanged - preserve enrichment data from old item
19
+ unchanged.push({
20
+ ...newItem,
21
+ github: oldItem.github,
22
+ lastEnriched: oldItem.lastEnriched,
23
+ });
24
+ } else {
25
+ // Updated - preserve enrichment data, use new metadata
26
+ updated.push({
27
+ ...newItem,
28
+ github: oldItem.github,
29
+ lastEnriched: oldItem.lastEnriched,
30
+ });
31
+ }
32
+ }
33
+
34
+ // Find removed items
35
+ for (const oldItem of oldItems) {
36
+ if (!newByUrl.has(oldItem.url)) {
37
+ removed.push(oldItem);
38
+ }
39
+ }
40
+
41
+ return { added, removed, unchanged, updated };
42
+ }
package/src/enricher.ts CHANGED
@@ -1,10 +1,29 @@
1
1
  // src/enricher.ts
2
- import { Octokit } from "octokit";
3
2
  import type { Item } from "./types";
4
3
 
5
4
  export function extractGitHubRepo(url: string): string | null {
6
5
  const match = url.match(/github\.com\/([^\/]+\/[^\/]+)/);
7
- return match ? match[1].replace(/\.git$/, "") : null;
6
+ if (!match) return null;
7
+
8
+ // Clean up repo name: remove .git, #readme, query params, etc.
9
+ let repo = match[1]
10
+ .replace(/\.git$/, "")
11
+ .replace(/#.*$/, "")
12
+ .replace(/\?.*$/, "");
13
+
14
+ // Skip non-repo paths like "topics/awesome", "sponsors/foo"
15
+ const invalidPrefixes = ["topics", "sponsors", "orgs", "settings", "marketplace"];
16
+ if (invalidPrefixes.some(p => repo.startsWith(p + "/"))) {
17
+ return null;
18
+ }
19
+
20
+ return repo;
21
+ }
22
+
23
+ // Sleep helper with jitter
24
+ function sleep(ms: number, jitter = 0.2): Promise<void> {
25
+ const jitterMs = ms * jitter * (Math.random() - 0.5) * 2;
26
+ return new Promise(resolve => setTimeout(resolve, ms + jitterMs));
8
27
  }
9
28
 
10
29
  export async function batchEnrichItems(items: Item[]): Promise<Item[]> {
@@ -14,8 +33,6 @@ export async function batchEnrichItems(items: Item[]): Promise<Item[]> {
14
33
  return items;
15
34
  }
16
35
 
17
- const octokit = new Octokit({ auth: token });
18
-
19
36
  // Extract unique GitHub repos
20
37
  const repoMap = new Map<string, Item[]>();
21
38
  for (const item of items) {
@@ -29,13 +46,26 @@ export async function batchEnrichItems(items: Item[]): Promise<Item[]> {
29
46
  const repos = Array.from(repoMap.keys());
30
47
  console.log(`Enriching ${repos.length} unique repos...`);
31
48
 
32
- // Batch query using GraphQL (100 at a time)
33
- const batchSize = 100;
49
+ // Tuned for GitHub's secondary rate limits
50
+ const batchSize = 50; // Smaller batches = lower query cost
51
+ const baseDelayMs = 500; // Base delay between batches
52
+ let currentDelay = baseDelayMs;
53
+ let consecutiveErrors = 0;
54
+
34
55
  for (let i = 0; i < repos.length; i += batchSize) {
35
56
  const batch = repos.slice(i, i + batchSize);
57
+ const batchNum = Math.floor(i / batchSize) + 1;
58
+ const totalBatches = Math.ceil(repos.length / batchSize);
59
+
60
+ // Add delay between batches (except first)
61
+ if (i > 0) {
62
+ await sleep(currentDelay);
63
+ }
36
64
 
65
+ // Include rateLimit in query to monitor usage
37
66
  const query = `
38
67
  query {
68
+ rateLimit { cost remaining resetAt }
39
69
  ${batch.map((repo, idx) => {
40
70
  const [owner, name] = repo.split("/");
41
71
  return `repo${idx}: repository(owner: "${owner}", name: "${name}") {
@@ -48,8 +78,55 @@ export async function batchEnrichItems(items: Item[]): Promise<Item[]> {
48
78
  `;
49
79
 
50
80
  try {
51
- const result: any = await octokit.graphql(query);
81
+ // Use fetch instead of octokit.graphql to handle partial results
82
+ const response = await fetch("https://api.github.com/graphql", {
83
+ method: "POST",
84
+ headers: {
85
+ Authorization: `Bearer ${token}`,
86
+ "Content-Type": "application/json",
87
+ },
88
+ body: JSON.stringify({ query }),
89
+ });
90
+
91
+ if (!response.ok) {
92
+ throw new Error(`HTTP ${response.status}`);
93
+ }
94
+
95
+ const json: any = await response.json();
96
+
97
+ // Check for complete failure (errors but no data)
98
+ if (json.errors && !json.data) {
99
+ throw new Error(json.errors[0]?.message || "GraphQL query failed");
100
+ }
101
+
102
+ // Log any partial errors (repos that don't exist, etc.)
103
+ if (json.errors) {
104
+ const failedRepos = json.errors
105
+ .filter((e: any) => e.path?.[0]?.startsWith("repo"))
106
+ .map((e: any) => batch[parseInt(e.path[0].slice(4))])
107
+ .filter(Boolean);
108
+ if (failedRepos.length > 0) {
109
+ console.log(` [${batchNum}/${totalBatches}] ${failedRepos.length} repos not found`);
110
+ }
111
+ }
112
+
113
+ const result = json.data;
114
+
115
+ // Log rate limit status periodically
116
+ const rl = result.rateLimit;
117
+ if (batchNum % 10 === 0 || rl?.remaining < 100) {
118
+ console.log(` [${batchNum}/${totalBatches}] Rate limit: ${rl?.remaining} remaining, cost: ${rl?.cost}`);
119
+ } else {
120
+ process.stdout.write(` [${batchNum}/${totalBatches}]\r`);
121
+ }
52
122
 
123
+ // If running low on points, slow down
124
+ if (rl?.remaining < 500) {
125
+ currentDelay = Math.min(currentDelay * 1.5, 5000);
126
+ console.log(` ⚠️ Low rate limit (${rl.remaining}), increasing delay to ${currentDelay}ms`);
127
+ }
128
+
129
+ // Process results - now handles partial data correctly
53
130
  for (let j = 0; j < batch.length; j++) {
54
131
  const data = result[`repo${j}`];
55
132
  if (data) {
@@ -64,10 +141,115 @@ export async function batchEnrichItems(items: Item[]): Promise<Item[]> {
64
141
  }
65
142
  }
66
143
  }
144
+
145
+ // Reset on success
146
+ consecutiveErrors = 0;
147
+ currentDelay = Math.max(currentDelay * 0.9, baseDelayMs); // Gradually speed up
148
+
67
149
  } catch (error: any) {
68
- console.error(`Error enriching batch ${i}-${i + batchSize}:`, error.message);
150
+ consecutiveErrors++;
151
+
152
+ // Check for secondary rate limit
153
+ if (error.message?.includes("SecondaryRateLimit") || error.message?.includes("403")) {
154
+ // Exponential backoff with jitter
155
+ const backoffMs = Math.min(baseDelayMs * Math.pow(2, consecutiveErrors), 60000);
156
+ console.log(` ⚠️ Secondary rate limit hit, backing off for ${backoffMs}ms...`);
157
+ await sleep(backoffMs, 0.3);
158
+ currentDelay = backoffMs; // Keep the higher delay
159
+ i -= batchSize; // Retry this batch
160
+ continue;
161
+ }
162
+
163
+ // Log other errors but continue
164
+ console.error(` Error batch ${batchNum}: ${error.message?.slice(0, 100)}`);
165
+
166
+ // If too many consecutive errors, slow down
167
+ if (consecutiveErrors >= 3) {
168
+ currentDelay = Math.min(currentDelay * 2, 10000);
169
+ console.log(` Multiple errors, slowing to ${currentDelay}ms`);
170
+ }
69
171
  }
70
172
  }
71
173
 
174
+ console.log(""); // Clear the \r line
72
175
  return items;
73
176
  }
177
+
178
+ export async function batchQueryListRepos(
179
+ repos: string[]
180
+ ): Promise<Map<string, { pushedAt: string } | null>> {
181
+ const results = new Map<string, { pushedAt: string } | null>();
182
+
183
+ const token = process.env.GITHUB_TOKEN;
184
+ if (!token) {
185
+ console.warn("No GITHUB_TOKEN - skipping list repo query");
186
+ return results;
187
+ }
188
+
189
+ const BATCH_SIZE = 50;
190
+
191
+ for (let i = 0; i < repos.length; i += BATCH_SIZE) {
192
+ const batch = repos.slice(i, i + BATCH_SIZE);
193
+ const batchNum = Math.floor(i / BATCH_SIZE) + 1;
194
+ const totalBatches = Math.ceil(repos.length / BATCH_SIZE);
195
+ process.stdout.write(` [${batchNum}/${totalBatches}]\r`);
196
+
197
+ // Build GraphQL query for this batch
198
+ const repoQueries = batch.map((repo, idx) => {
199
+ const [owner, name] = repo.split("/");
200
+ return `repo${idx}: repository(owner: "${owner}", name: "${name}") {
201
+ pushedAt
202
+ }`;
203
+ });
204
+
205
+ const query = `query { ${repoQueries.join("\n")} }`;
206
+
207
+ try {
208
+ const response = await fetch("https://api.github.com/graphql", {
209
+ method: "POST",
210
+ headers: {
211
+ Authorization: `Bearer ${token}`,
212
+ "Content-Type": "application/json",
213
+ },
214
+ body: JSON.stringify({ query }),
215
+ });
216
+
217
+ if (!response.ok) {
218
+ console.error(` Error batch ${batchNum}: HTTP ${response.status}`);
219
+ for (const repo of batch) {
220
+ results.set(repo, null);
221
+ }
222
+ continue;
223
+ }
224
+
225
+ const json = await response.json();
226
+
227
+ if (json.errors && !json.data) {
228
+ console.error(` Error batch ${batchNum}:`, json.errors[0]?.message);
229
+ // Mark all repos in batch as null
230
+ for (const repo of batch) {
231
+ results.set(repo, null);
232
+ }
233
+ continue;
234
+ }
235
+
236
+ // Extract results
237
+ batch.forEach((repo, idx) => {
238
+ const data = json.data?.[`repo${idx}`];
239
+ if (data?.pushedAt) {
240
+ results.set(repo, { pushedAt: data.pushedAt });
241
+ } else {
242
+ results.set(repo, null);
243
+ }
244
+ });
245
+ } catch (error: any) {
246
+ console.error(` Error batch ${batchNum}:`, error.message);
247
+ for (const repo of batch) {
248
+ results.set(repo, null);
249
+ }
250
+ }
251
+ }
252
+
253
+ console.log(); // newline after progress
254
+ return results;
255
+ }
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import MiniSearch from "minisearch";
5
5
  import { z } from "zod";
6
- import type { ItemsIndex } from "./types";
6
+ import type { ItemsIndex, Item } from "./types";
7
7
 
8
8
  interface AwesomeList {
9
9
  repo: string;
@@ -14,14 +14,30 @@ interface AwesomeList {
14
14
  source: string;
15
15
  }
16
16
 
17
- // Data URL - GitHub raw (no file size limits, 5 min cache)
18
- const DATA_URL = "https://raw.githubusercontent.com/arimxyer/ass/main/data";
17
+ interface IndexedItem extends Item {
18
+ id: number;
19
+ listRepo: string;
20
+ }
21
+
22
+ // CDN URLs - jsDelivr primary (faster, global CDN), GitHub raw fallback
23
+ const JSDELIVR_URL = "https://cdn.jsdelivr.net/gh/arimxyer/ass@main/data";
24
+ const GITHUB_RAW_URL = "https://raw.githubusercontent.com/arimxyer/ass/main/data";
19
25
 
20
- // Load data from GitHub, fallback to local for development
26
+ // Load data from CDN, fallback to local for development
21
27
  async function loadData<T>(filename: string): Promise<T> {
22
- // Try remote first
28
+ // Try jsDelivr first (faster global CDN)
29
+ try {
30
+ const res = await fetch(`${JSDELIVR_URL}/${filename}`);
31
+ if (res.ok) {
32
+ return res.json();
33
+ }
34
+ } catch {
35
+ // jsDelivr failed, try GitHub raw
36
+ }
37
+
38
+ // Try GitHub raw as fallback
23
39
  try {
24
- const res = await fetch(`${DATA_URL}/${filename}`);
40
+ const res = await fetch(`${GITHUB_RAW_URL}/${filename}`);
25
41
  if (res.ok) {
26
42
  return res.json();
27
43
  }
@@ -34,11 +50,44 @@ async function loadData<T>(filename: string): Promise<T> {
34
50
  return Bun.file(localPath).json();
35
51
  }
36
52
 
53
+ // Load gzipped data from CDN, fallback to local
54
+ async function loadGzippedData<T>(filename: string): Promise<T> {
55
+ // Try jsDelivr first
56
+ try {
57
+ const res = await fetch(`${JSDELIVR_URL}/${filename}`);
58
+ if (res.ok) {
59
+ const compressed = new Uint8Array(await res.arrayBuffer());
60
+ const decompressed = Bun.gunzipSync(compressed);
61
+ return JSON.parse(new TextDecoder().decode(decompressed));
62
+ }
63
+ } catch {
64
+ // jsDelivr failed, try GitHub raw
65
+ }
66
+
67
+ // Try GitHub raw as fallback
68
+ try {
69
+ const res = await fetch(`${GITHUB_RAW_URL}/${filename}`);
70
+ if (res.ok) {
71
+ const compressed = new Uint8Array(await res.arrayBuffer());
72
+ const decompressed = Bun.gunzipSync(compressed);
73
+ return JSON.parse(new TextDecoder().decode(decompressed));
74
+ }
75
+ } catch {
76
+ // Remote failed, try local
77
+ }
78
+
79
+ // Fallback to local file
80
+ const localPath = new URL(`../data/${filename}`, import.meta.url);
81
+ const compressed = new Uint8Array(await Bun.file(localPath).arrayBuffer());
82
+ const decompressed = Bun.gunzipSync(compressed);
83
+ return JSON.parse(new TextDecoder().decode(decompressed));
84
+ }
85
+
37
86
  // Load curated data
38
87
  const lists: AwesomeList[] = await loadData("lists.json");
39
88
 
40
- // Initialize search index
41
- const search = new MiniSearch<AwesomeList>({
89
+ // Initialize list search index
90
+ const listSearch = new MiniSearch<AwesomeList>({
42
91
  fields: ["name", "repo", "description"],
43
92
  storeFields: ["repo", "name", "stars", "description", "pushed_at", "source"],
44
93
  searchOptions: {
@@ -48,235 +97,216 @@ const search = new MiniSearch<AwesomeList>({
48
97
  },
49
98
  });
50
99
 
51
- // Index all lists with repo as id
52
- search.addAll(lists.map((list, i) => ({ id: i, ...list })));
100
+ // Index all lists
101
+ listSearch.addAll(lists.map((list, i) => ({ id: i, ...list })));
53
102
 
54
- // Create MCP server
55
- const server = new McpServer({
56
- name: "ass",
57
- version: "0.1.0",
58
- });
103
+ // Load items index
104
+ let itemsIndex: ItemsIndex | null = null;
105
+ let allItems: IndexedItem[] = [];
106
+ let itemSearch: MiniSearch<IndexedItem> | null = null;
59
107
 
60
- // Tool: Search awesome lists
61
- server.tool(
62
- "search",
63
- "Search curated awesome lists by keyword. Returns matching lists sorted by relevance and stars.",
64
- {
65
- query: z.string().describe("Search query (e.g., 'rust', 'machine learning', 'react')"),
66
- limit: z.number().optional().describe("Maximum results to return (default: 10)"),
67
- minStars: z.number().optional().describe("Minimum star count filter (default: 0)"),
68
- },
69
- async ({ query, limit = 10, minStars = 0 }) => {
70
- const results = search
71
- .search(query)
72
- .filter((r) => r.stars >= minStars)
73
- .slice(0, limit)
74
- .map((r) => ({
75
- repo: r.repo,
76
- name: r.name,
77
- stars: r.stars,
78
- description: r.description,
79
- lastUpdated: r.pushed_at,
80
- score: r.score,
81
- }));
108
+ try {
109
+ itemsIndex = await loadGzippedData<ItemsIndex>("items.json.gz");
82
110
 
83
- return {
84
- content: [
85
- {
86
- type: "text",
87
- text: JSON.stringify(results, null, 2),
88
- },
89
- ],
90
- };
111
+ // Build flat list of all items for global search
112
+ let itemId = 0;
113
+ for (const [listRepo, entry] of Object.entries(itemsIndex.lists)) {
114
+ for (const item of entry.items) {
115
+ allItems.push({ ...item, id: itemId++, listRepo });
116
+ }
91
117
  }
92
- );
93
118
 
94
- // Tool: Get list details
119
+ // Initialize item search index
120
+ itemSearch = new MiniSearch<IndexedItem>({
121
+ fields: ["name", "description", "category"],
122
+ storeFields: ["name", "url", "description", "category", "subcategory", "github", "listRepo"],
123
+ searchOptions: {
124
+ boost: { name: 2, category: 1.5, description: 1 },
125
+ fuzzy: 0.2,
126
+ prefix: true,
127
+ },
128
+ });
129
+
130
+ itemSearch.addAll(allItems);
131
+
132
+ console.error(`Loaded ${itemsIndex.itemCount} items from ${itemsIndex.listCount} lists`);
133
+ } catch {
134
+ console.error("No items.json.gz found - item search will be unavailable");
135
+ }
136
+
137
+ // Create MCP server
138
+ const server = new McpServer({
139
+ name: "awess",
140
+ version: "0.3.0",
141
+ });
142
+
143
+ // Tool: Search awesome lists (unified search, top_lists, get_list)
95
144
  server.tool(
96
- "get_list",
97
- "Get details for a specific awesome list by repository name",
145
+ "search_lists",
146
+ "Search curated awesome lists. Returns lists sorted by relevance (if query provided) or stars.",
98
147
  {
99
- repo: z.string().describe("Repository name (e.g., 'sindresorhus/awesome')"),
148
+ query: z.string().optional().describe("Search query (e.g., 'rust', 'machine learning')"),
149
+ repo: z.string().optional().describe("Exact repo lookup (e.g., 'sindresorhus/awesome')"),
150
+ sortBy: z.enum(["relevance", "stars", "updated"]).optional().describe("Sort order (default: 'stars', or 'relevance' if query provided)"),
151
+ minStars: z.number().optional().describe("Minimum star count filter"),
152
+ limit: z.number().optional().describe("Maximum results (default: 20)"),
100
153
  },
101
- async ({ repo }) => {
102
- const list = lists.find(
103
- (l) => l.repo.toLowerCase() === repo.toLowerCase()
104
- );
105
-
106
- if (!list) {
154
+ async ({ query, repo, sortBy, minStars = 0, limit = 20 }) => {
155
+ // Exact repo lookup
156
+ if (repo) {
157
+ const list = lists.find(l => l.repo.toLowerCase() === repo.toLowerCase());
158
+ if (!list) {
159
+ return {
160
+ content: [{ type: "text", text: `List not found: ${repo}` }],
161
+ };
162
+ }
107
163
  return {
108
- content: [
109
- {
110
- type: "text",
111
- text: `List not found: ${repo}`,
112
- },
113
- ],
164
+ content: [{
165
+ type: "text",
166
+ text: JSON.stringify({
167
+ repo: list.repo,
168
+ name: list.name,
169
+ stars: list.stars,
170
+ description: list.description,
171
+ lastUpdated: list.pushed_at,
172
+ source: list.source,
173
+ githubUrl: `https://github.com/${list.repo}`,
174
+ }, null, 2),
175
+ }],
114
176
  };
115
177
  }
116
178
 
117
- return {
118
- content: [
119
- {
120
- type: "text",
121
- text: JSON.stringify(
122
- {
123
- repo: list.repo,
124
- name: list.name,
125
- stars: list.stars,
126
- description: list.description,
127
- lastUpdated: list.pushed_at,
128
- source: list.source,
129
- githubUrl: `https://github.com/${list.repo}`,
130
- },
131
- null,
132
- 2
133
- ),
134
- },
135
- ],
136
- };
137
- }
138
- );
179
+ // Determine sort order
180
+ const effectiveSortBy = sortBy || (query ? "relevance" : "stars");
139
181
 
140
- // Tool: List top repos by stars
141
- server.tool(
142
- "top_lists",
143
- "Get top awesome lists by star count",
144
- {
145
- limit: z.number().optional().describe("Number of lists to return (default: 20)"),
146
- category: z.string().optional().describe("Optional keyword to filter by (e.g., 'python', 'web')"),
147
- },
148
- async ({ limit = 20, category }) => {
149
- let filtered = lists;
150
-
151
- if (category) {
152
- const categoryResults = search.search(category);
153
- filtered = categoryResults.map((r) => ({
154
- repo: r.repo,
155
- name: r.name,
156
- stars: r.stars,
157
- description: r.description,
158
- pushed_at: r.pushed_at,
159
- source: r.source,
160
- }));
161
- }
182
+ let results: any[];
162
183
 
163
- const top = filtered
164
- .sort((a, b) => b.stars - a.stars)
165
- .slice(0, limit)
166
- .map((l) => ({
184
+ if (query && effectiveSortBy === "relevance") {
185
+ // Search with relevance sorting
186
+ results = listSearch
187
+ .search(query)
188
+ .filter(r => r.stars >= minStars)
189
+ .slice(0, limit)
190
+ .map(r => ({
191
+ repo: r.repo,
192
+ name: r.name,
193
+ stars: r.stars,
194
+ description: r.description,
195
+ lastUpdated: r.pushed_at,
196
+ }));
197
+ } else {
198
+ // Filter and sort manually
199
+ let filtered = lists.filter(l => l.stars >= minStars);
200
+
201
+ if (query) {
202
+ const searchResults = new Set(listSearch.search(query).map(r => r.repo));
203
+ filtered = filtered.filter(l => searchResults.has(l.repo));
204
+ }
205
+
206
+ // Sort
207
+ if (effectiveSortBy === "updated") {
208
+ filtered.sort((a, b) => (b.pushed_at || "").localeCompare(a.pushed_at || ""));
209
+ } else {
210
+ filtered.sort((a, b) => b.stars - a.stars);
211
+ }
212
+
213
+ results = filtered.slice(0, limit).map(l => ({
167
214
  repo: l.repo,
168
215
  name: l.name,
169
216
  stars: l.stars,
170
- description: l.description?.slice(0, 100),
217
+ description: l.description,
218
+ lastUpdated: l.pushed_at,
171
219
  }));
220
+ }
172
221
 
173
222
  return {
174
- content: [
175
- {
176
- type: "text",
177
- text: JSON.stringify(top, null, 2),
178
- },
179
- ],
180
- };
181
- }
182
- );
183
-
184
- // Tool: Stats
185
- server.tool(
186
- "stats",
187
- "Get statistics about the curated awesome lists collection",
188
- {},
189
- async () => {
190
- const totalLists = lists.length;
191
- const totalStars = lists.reduce((sum, l) => sum + l.stars, 0);
192
- const avgStars = Math.round(totalStars / totalLists);
193
-
194
- const starBrackets = {
195
- "10000+": lists.filter((l) => l.stars >= 10000).length,
196
- "5000-9999": lists.filter((l) => l.stars >= 5000 && l.stars < 10000)
197
- .length,
198
- "1000-4999": lists.filter((l) => l.stars >= 1000 && l.stars < 5000)
199
- .length,
200
- "500-999": lists.filter((l) => l.stars >= 500 && l.stars < 1000).length,
201
- "<500": lists.filter((l) => l.stars < 500).length,
202
- };
203
-
204
- return {
205
- content: [
206
- {
207
- type: "text",
208
- text: JSON.stringify(
209
- {
210
- totalLists,
211
- totalStars,
212
- avgStars,
213
- starDistribution: starBrackets,
214
- },
215
- null,
216
- 2
217
- ),
218
- },
219
- ],
223
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
220
224
  };
221
225
  }
222
226
  );
223
227
 
224
- // Load items index
225
- let itemsIndex: ItemsIndex | null = null;
226
-
227
- try {
228
- itemsIndex = await loadData<ItemsIndex>("items.json");
229
- console.error(`Loaded ${itemsIndex?.itemCount} items from ${itemsIndex?.listCount} lists`);
230
- } catch {
231
- console.error("No items.json found - get_items will be unavailable");
232
- }
233
-
234
- // Tool: Get items from a list
235
- if (itemsIndex) {
228
+ // Tool: Search items (global search with filters)
229
+ if (itemsIndex && itemSearch) {
236
230
  server.tool(
237
- "get_items",
238
- "Get resources/items from an awesome list. Returns tools, libraries, and resources curated in the list.",
231
+ "search_items",
232
+ "Search tools, libraries, and resources across all awesome lists. Supports global search or filtering within a specific list.",
239
233
  {
240
- repo: z.string().describe("Repository name (e.g., 'vinta/awesome-python')"),
241
- category: z.string().optional().describe("Filter by category/section name"),
242
- limit: z.number().optional().describe("Maximum items to return (default: 50)"),
234
+ query: z.string().optional().describe("Search item names/descriptions"),
235
+ repo: z.string().optional().describe("Limit to a specific list (e.g., 'vinta/awesome-python')"),
236
+ category: z.string().optional().describe("Filter by category"),
237
+ language: z.string().optional().describe("Filter by programming language"),
238
+ minStars: z.number().optional().describe("Minimum GitHub stars"),
239
+ sortBy: z.enum(["relevance", "stars", "updated"]).optional().describe("Sort order (default: 'stars', or 'relevance' if query provided)"),
240
+ limit: z.number().optional().describe("Maximum results (default: 50)"),
243
241
  },
244
- async ({ repo, category, limit = 50 }) => {
245
- const listEntry = itemsIndex!.lists[repo] || itemsIndex!.lists[repo.toLowerCase()];
242
+ async ({ query, repo, category, language, minStars = 0, sortBy, limit = 50 }) => {
243
+ const effectiveSortBy = sortBy || (query ? "relevance" : "stars");
246
244
 
247
- if (!listEntry) {
248
- return {
249
- content: [
250
- {
251
- type: "text",
252
- text: `List not found: ${repo}. Use the 'search' tool to find available lists.`,
253
- },
254
- ],
255
- };
245
+ let results: IndexedItem[];
246
+
247
+ if (query && effectiveSortBy === "relevance") {
248
+ // Search with relevance
249
+ results = itemSearch!.search(query).map(r => ({
250
+ id: r.id,
251
+ name: r.name,
252
+ url: r.url,
253
+ description: r.description,
254
+ category: r.category,
255
+ subcategory: r.subcategory,
256
+ github: r.github,
257
+ listRepo: r.listRepo,
258
+ })) as IndexedItem[];
259
+ } else {
260
+ // Start with all items or query results
261
+ if (query) {
262
+ const searchResultIds = new Set(itemSearch!.search(query).map(r => r.id));
263
+ results = allItems.filter(i => searchResultIds.has(i.id));
264
+ } else {
265
+ results = [...allItems];
266
+ }
256
267
  }
257
268
 
258
- let items = listEntry.items;
269
+ // Apply filters
270
+ if (repo) {
271
+ const repoLower = repo.toLowerCase();
272
+ results = results.filter(i => i.listRepo.toLowerCase() === repoLower);
273
+ }
259
274
 
260
- // Filter by category if provided
261
275
  if (category) {
262
- const categoryLower = category.toLowerCase();
263
- items = items.filter(
264
- (i) =>
265
- i.category.toLowerCase().includes(categoryLower) ||
266
- i.subcategory?.toLowerCase().includes(categoryLower)
276
+ const catLower = category.toLowerCase();
277
+ results = results.filter(i =>
278
+ i.category?.toLowerCase().includes(catLower) ||
279
+ i.subcategory?.toLowerCase().includes(catLower)
267
280
  );
268
281
  }
269
282
 
270
- // Apply limit
271
- items = items.slice(0, limit);
283
+ if (language) {
284
+ const langLower = language.toLowerCase();
285
+ results = results.filter(i =>
286
+ i.github?.language?.toLowerCase() === langLower
287
+ );
288
+ }
272
289
 
273
- // Format output
274
- const result = {
275
- repo,
276
- totalItems: listEntry.items.length,
277
- returnedItems: items.length,
278
- lastParsed: listEntry.lastParsed,
279
- items: items.map((i) => ({
290
+ if (minStars > 0) {
291
+ results = results.filter(i => (i.github?.stars || 0) >= minStars);
292
+ }
293
+
294
+ // Sort (if not already sorted by relevance)
295
+ if (effectiveSortBy === "stars") {
296
+ results.sort((a, b) => (b.github?.stars || 0) - (a.github?.stars || 0));
297
+ } else if (effectiveSortBy === "updated") {
298
+ results.sort((a, b) =>
299
+ (b.github?.pushedAt || "").localeCompare(a.github?.pushedAt || "")
300
+ );
301
+ }
302
+
303
+ // Apply limit and format
304
+ const limited = results.slice(0, limit);
305
+
306
+ const output = {
307
+ totalMatches: results.length,
308
+ returned: limited.length,
309
+ items: limited.map(i => ({
280
310
  name: i.name,
281
311
  url: i.url,
282
312
  description: i.description,
@@ -284,21 +314,92 @@ if (itemsIndex) {
284
314
  subcategory: i.subcategory,
285
315
  stars: i.github?.stars,
286
316
  language: i.github?.language,
317
+ lastUpdated: i.github?.pushedAt,
318
+ list: i.listRepo,
287
319
  })),
288
320
  };
289
321
 
290
322
  return {
291
- content: [
292
- {
293
- type: "text",
294
- text: JSON.stringify(result, null, 2),
295
- },
296
- ],
323
+ content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
297
324
  };
298
325
  }
299
326
  );
300
327
  }
301
328
 
329
+ // Tool: Stats (enhanced with item statistics)
330
+ server.tool(
331
+ "stats",
332
+ "Get statistics about the curated awesome lists collection and items",
333
+ {},
334
+ async () => {
335
+ const totalLists = lists.length;
336
+ const totalStars = lists.reduce((sum, l) => sum + l.stars, 0);
337
+ const avgStars = Math.round(totalStars / totalLists);
338
+
339
+ const listStarBrackets = {
340
+ "10000+": lists.filter(l => l.stars >= 10000).length,
341
+ "5000-9999": lists.filter(l => l.stars >= 5000 && l.stars < 10000).length,
342
+ "1000-4999": lists.filter(l => l.stars >= 1000 && l.stars < 5000).length,
343
+ "500-999": lists.filter(l => l.stars >= 500 && l.stars < 1000).length,
344
+ "<500": lists.filter(l => l.stars < 500).length,
345
+ };
346
+
347
+ // Item statistics
348
+ let itemStats: any = null;
349
+ if (itemsIndex && allItems.length > 0) {
350
+ const enrichedItems = allItems.filter(i => i.github);
351
+
352
+ // Count languages
353
+ const languageCounts = new Map<string, number>();
354
+ for (const item of enrichedItems) {
355
+ const lang = item.github?.language;
356
+ if (lang) {
357
+ languageCounts.set(lang, (languageCounts.get(lang) || 0) + 1);
358
+ }
359
+ }
360
+ const topLanguages = [...languageCounts.entries()]
361
+ .sort((a, b) => b[1] - a[1])
362
+ .slice(0, 10)
363
+ .reduce((acc, [lang, count]) => ({ ...acc, [lang]: count }), {});
364
+
365
+ // Count categories
366
+ const categoryCounts = new Map<string, number>();
367
+ for (const item of allItems) {
368
+ if (item.category) {
369
+ categoryCounts.set(item.category, (categoryCounts.get(item.category) || 0) + 1);
370
+ }
371
+ }
372
+ const topCategories = [...categoryCounts.entries()]
373
+ .sort((a, b) => b[1] - a[1])
374
+ .slice(0, 10)
375
+ .reduce((acc, [cat, count]) => ({ ...acc, [cat]: count }), {});
376
+
377
+ itemStats = {
378
+ totalItems: allItems.length,
379
+ enrichedItems: enrichedItems.length,
380
+ listsWithItems: itemsIndex.listCount,
381
+ topLanguages,
382
+ topCategories,
383
+ };
384
+ }
385
+
386
+ return {
387
+ content: [{
388
+ type: "text",
389
+ text: JSON.stringify({
390
+ lists: {
391
+ total: totalLists,
392
+ totalStars,
393
+ avgStars,
394
+ starDistribution: listStarBrackets,
395
+ },
396
+ items: itemStats,
397
+ }, null, 2),
398
+ }],
399
+ };
400
+ }
401
+ );
402
+
302
403
  // Start server
303
404
  const transport = new StdioServerTransport();
304
405
  await server.connect(transport);
package/src/types.ts CHANGED
@@ -26,3 +26,10 @@ export interface ItemsIndex {
26
26
  itemCount: number;
27
27
  lists: Record<string, ListEntry>;
28
28
  }
29
+
30
+ export interface DiffResult {
31
+ added: Item[];
32
+ removed: Item[];
33
+ unchanged: Item[];
34
+ updated: Item[];
35
+ }