freshcontext-mcp 0.3.14 → 0.3.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.
- package/.env.example +8 -0
- package/README.md +117 -125
- package/RESEARCH.md +487 -0
- package/RISKS.md +137 -0
- package/cleanup.ps1 +99 -0
- package/demo/README.md +70 -0
- package/demo/data.json +88 -0
- package/demo/generate.mjs +199 -0
- package/demo/index.html +513 -0
- package/demo/logo-export.html +61 -0
- package/demo/logo.svg +23 -0
- package/dist/server.js +124 -66
- package/dist/tools/freshnessStamp.js +30 -22
- package/freshcontext-validate.js +196 -0
- package/freshcontext.schema.json +103 -0
- package/package.json +2 -2
- package/server.json +3 -3
- package/time-check.ps1 +46 -0
- package/.actor/Dockerfile +0 -16
- package/.actor/actor.json +0 -9
- package/.actor/output_schema.json +0 -13
- package/ARCHITECTURE_UPGRADE_CHECKLIST.md +0 -88
- package/ARCHITECTURE_UPGRADE_ROADMAP_V1.md +0 -174
- package/FRESHCONTEXT_SPEC.md +0 -178
- package/HANDOFF.md +0 -184
- package/ROADMAP.md +0 -174
- package/SESSION_SAVE_ARCHITECTURE_V1.md +0 -67
- package/SESSION_SAVE_ARCHITECTURE_V2.md +0 -142
- package/SESSION_SAVE_V4.md +0 -60
- package/SESSION_SAVE_V5.md +0 -121
- package/USAGE.md +0 -294
- package/add-cache.cjs +0 -86
- package/dataset_schema.json +0 -41
- package/input_schema.json +0 -48
package/dist/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { packageTrendsAdapter } from "./adapters/packageTrends.js";
|
|
|
11
11
|
import { redditAdapter } from "./adapters/reddit.js";
|
|
12
12
|
import { productHuntAdapter } from "./adapters/productHunt.js";
|
|
13
13
|
import { financeAdapter } from "./adapters/finance.js";
|
|
14
|
+
import { arxivAdapter } from "./adapters/arxiv.js";
|
|
14
15
|
import { jobsAdapter } from "./adapters/jobs.js";
|
|
15
16
|
import { changelogAdapter } from "./adapters/changelog.js";
|
|
16
17
|
import { govContractsAdapter } from "./adapters/govcontracts.js";
|
|
@@ -88,7 +89,7 @@ server.registerTool("extract_yc", {
|
|
|
88
89
|
}, async ({ url, max_length }) => {
|
|
89
90
|
try {
|
|
90
91
|
const result = await ycAdapter({ url, maxLength: max_length });
|
|
91
|
-
const ctx = stampFreshness(result, { url, maxLength: max_length }, "
|
|
92
|
+
const ctx = stampFreshness(result, { url, maxLength: max_length }, "yc");
|
|
92
93
|
return { content: [{ type: "text", text: formatForLLM(ctx) }] };
|
|
93
94
|
}
|
|
94
95
|
catch (err) {
|
|
@@ -106,7 +107,7 @@ server.registerTool("search_repos", {
|
|
|
106
107
|
}, async ({ query, max_length }) => {
|
|
107
108
|
try {
|
|
108
109
|
const result = await repoSearchAdapter({ url: query, maxLength: max_length });
|
|
109
|
-
const ctx = stampFreshness(result, { url: query, maxLength: max_length }, "
|
|
110
|
+
const ctx = stampFreshness(result, { url: query, maxLength: max_length }, "reposearch");
|
|
110
111
|
return { content: [{ type: "text", text: formatForLLM(ctx) }] };
|
|
111
112
|
}
|
|
112
113
|
catch (err) {
|
|
@@ -124,13 +125,97 @@ server.registerTool("package_trends", {
|
|
|
124
125
|
}, async ({ packages, max_length }) => {
|
|
125
126
|
try {
|
|
126
127
|
const result = await packageTrendsAdapter({ url: packages, maxLength: max_length });
|
|
127
|
-
const ctx = stampFreshness(result, { url: packages, maxLength: max_length }, "
|
|
128
|
+
const ctx = stampFreshness(result, { url: packages, maxLength: max_length }, "packagetrends");
|
|
128
129
|
return { content: [{ type: "text", text: formatForLLM(ctx) }] };
|
|
129
130
|
}
|
|
130
131
|
catch (err) {
|
|
131
132
|
return { content: [{ type: "text", text: formatSecurityError(err) }] };
|
|
132
133
|
}
|
|
133
134
|
});
|
|
135
|
+
// ─── Tool: extract_arxiv ─────────────────────────────────────────────────────
|
|
136
|
+
server.registerTool("extract_arxiv", {
|
|
137
|
+
description: "Search arXiv for research papers via the official API. Pass a topic, keyword, or full arXiv API URL. Returns titles, authors, publication dates, primary category, and abstracts — all timestamped.",
|
|
138
|
+
inputSchema: z.object({
|
|
139
|
+
url: z.string().describe("Search query e.g. 'temporal retrieval', or a full arXiv API URL"),
|
|
140
|
+
max_length: z.number().optional().default(6000),
|
|
141
|
+
}),
|
|
142
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
143
|
+
}, async ({ url, max_length }) => {
|
|
144
|
+
try {
|
|
145
|
+
const result = await arxivAdapter({ url, maxLength: max_length });
|
|
146
|
+
const ctx = stampFreshness(result, { url, maxLength: max_length }, "arxiv");
|
|
147
|
+
return { content: [{ type: "text", text: formatForLLM(ctx) }] };
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
return { content: [{ type: "text", text: formatSecurityError(err) }] };
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
// ─── Tool: extract_finance ───────────────────────────────────────────────────
|
|
154
|
+
server.registerTool("extract_finance", {
|
|
155
|
+
description: "Live stock data via Yahoo Finance — price, change, market cap, P/E, 52w range, sector, business summary. Accepts up to 5 comma-separated tickers. Returns timestamped freshcontext.",
|
|
156
|
+
inputSchema: z.object({
|
|
157
|
+
url: z.string().describe("Ticker symbol(s) e.g. 'AAPL' or 'MSFT,GOOG,PLTR'"),
|
|
158
|
+
max_length: z.number().optional().default(5000),
|
|
159
|
+
}),
|
|
160
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
161
|
+
}, async ({ url, max_length }) => {
|
|
162
|
+
try {
|
|
163
|
+
const result = await financeAdapter({ url, maxLength: max_length });
|
|
164
|
+
const ctx = stampFreshness(result, { url, maxLength: max_length }, "finance");
|
|
165
|
+
return { content: [{ type: "text", text: formatForLLM(ctx) }] };
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
return { content: [{ type: "text", text: formatSecurityError(err) }] };
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
// ─── Tool: extract_reddit ────────────────────────────────────────────────────
|
|
172
|
+
server.registerTool("extract_reddit", {
|
|
173
|
+
description: "Extract posts and community sentiment from Reddit via the public JSON API. Accepts a subreddit URL (https://www.reddit.com/r/MachineLearning/.json), a search URL, or a subreddit shorthand ('r/MachineLearning'). Returns titles, authors, scores, comment counts, and per-post timestamps.",
|
|
174
|
+
inputSchema: z.object({
|
|
175
|
+
url: z.string().describe("Subreddit URL, search URL, or 'r/<subreddit>' shorthand"),
|
|
176
|
+
max_length: z.number().optional().default(6000),
|
|
177
|
+
}),
|
|
178
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
179
|
+
}, async ({ url, max_length }) => {
|
|
180
|
+
try {
|
|
181
|
+
const result = await redditAdapter({ url, maxLength: max_length });
|
|
182
|
+
const ctx = stampFreshness(result, { url, maxLength: max_length }, "reddit");
|
|
183
|
+
return { content: [{ type: "text", text: formatForLLM(ctx) }] };
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
return { content: [{ type: "text", text: formatSecurityError(err) }] };
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
// ─── Tool: extract_producthunt ───────────────────────────────────────────────
|
|
190
|
+
server.registerTool("extract_producthunt", {
|
|
191
|
+
description: "Recent Product Hunt launches by keyword or topic. Uses the Product Hunt GraphQL API (with HTML scrape fallback). Returns names, taglines, vote counts, comment counts, topics, and launch dates — all timestamped.",
|
|
192
|
+
inputSchema: z.object({
|
|
193
|
+
url: z.string().describe("Search query e.g. 'mcp ai agents' or a Product Hunt topic URL"),
|
|
194
|
+
max_length: z.number().optional().default(6000),
|
|
195
|
+
}),
|
|
196
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
197
|
+
}, async ({ url, max_length }) => {
|
|
198
|
+
try {
|
|
199
|
+
const result = await productHuntAdapter({ url, maxLength: max_length });
|
|
200
|
+
const ctx = stampFreshness(result, { url, maxLength: max_length }, "producthunt");
|
|
201
|
+
return { content: [{ type: "text", text: formatForLLM(ctx) }] };
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
return { content: [{ type: "text", text: formatSecurityError(err) }] };
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
function sectionWithFreshnessCheck(label, result, adapterName, minScore, errorWord = "Unavailable") {
|
|
208
|
+
if (result.status !== "fulfilled") {
|
|
209
|
+
return `## ${label}\n[${errorWord}: ${result.reason}]`;
|
|
210
|
+
}
|
|
211
|
+
if (minScore !== undefined && minScore > 0) {
|
|
212
|
+
const ctx = stampFreshness(result.value, { url: "", maxLength: 0 }, adapterName);
|
|
213
|
+
if (ctx.freshness_score !== null && ctx.freshness_score < minScore) {
|
|
214
|
+
return `## ${label}\n[Stale — freshness_score: ${ctx.freshness_score}/100 is below min_freshness_score threshold of ${minScore}. Content date: ${result.value.content_date ?? "unknown"}. Re-query for fresher data.]`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return `## ${label}\n${result.value.raw}`;
|
|
218
|
+
}
|
|
134
219
|
// ─── Tool: extract_landscape ─────────────────────────────────────────────────
|
|
135
220
|
server.registerTool("extract_landscape", {
|
|
136
221
|
description: "Composite intelligence tool. Given a project idea or keyword, simultaneously queries YC startups, GitHub repos, HN, Reddit, Product Hunt, and package registries to answer: Who is building this? Is it funded? What's getting traction? Returns a unified 6-source timestamped landscape report.",
|
|
@@ -237,37 +322,31 @@ server.registerTool("extract_gov_landscape", {
|
|
|
237
322
|
query: z.string().describe("Company name (e.g. 'Palantir'), keyword (e.g. 'artificial intelligence'), or NAICS code (e.g. '541511'). For GitHub and changelog sections, also optionally provide a GitHub URL."),
|
|
238
323
|
github_url: z.string().optional().describe("Optional GitHub repo URL for the company (e.g. 'https://github.com/palantir/palantir-java-format'). If omitted, GitHub and changelog sections use the query as a search term."),
|
|
239
324
|
max_length: z.number().optional().default(12000),
|
|
325
|
+
min_freshness_score: z.number().optional().describe("Filter sections below this freshness_score (0–100). E.g. 70 = only recently retrieved data."),
|
|
240
326
|
}),
|
|
241
327
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
242
|
-
}, async ({ query, github_url, max_length }) => {
|
|
328
|
+
}, async ({ query, github_url, max_length, min_freshness_score }) => {
|
|
243
329
|
const perSection = Math.floor((max_length ?? 12000) / 4);
|
|
244
|
-
// All four sources fire in parallel — if one fails the others still return
|
|
245
330
|
const [contractsResult, hnResult, repoResult, changelogResult] = await Promise.allSettled([
|
|
246
|
-
// 1. The anchor: who is actually winning federal money in this space
|
|
247
331
|
govContractsAdapter({ url: query, maxLength: perSection }),
|
|
248
|
-
// 2. Dev community signal: does anyone in tech know about these companies
|
|
249
332
|
hackerNewsAdapter({
|
|
250
333
|
url: `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(query)}&tags=story&hitsPerPage=10`,
|
|
251
334
|
maxLength: perSection,
|
|
252
335
|
}),
|
|
253
|
-
// 3. GitHub activity: are they actually building, or contract farmers
|
|
254
336
|
repoSearchAdapter({ url: github_url ?? query, maxLength: perSection }),
|
|
255
|
-
// 4. Release velocity: how fast are they shipping product
|
|
256
337
|
changelogAdapter({ url: github_url ?? query, maxLength: perSection }),
|
|
257
338
|
]);
|
|
258
|
-
const section = (label, result) => result.status === "fulfilled"
|
|
259
|
-
? `## ${label}\n${result.value.raw}`
|
|
260
|
-
: `## ${label}\n[Unavailable: ${result.reason}]`;
|
|
261
339
|
const combined = [
|
|
262
340
|
`# Government Intelligence Landscape: "${query}"`,
|
|
263
341
|
`Generated: ${new Date().toISOString()}`,
|
|
264
342
|
`Sources: USASpending.gov · Hacker News · GitHub · Changelog`,
|
|
343
|
+
min_freshness_score ? `min_freshness_score: ${min_freshness_score}` : null,
|
|
265
344
|
"",
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
].join("\n\n");
|
|
345
|
+
sectionWithFreshnessCheck("🏛️ Federal Contract Awards (USASpending.gov)", contractsResult, "govcontracts", min_freshness_score),
|
|
346
|
+
sectionWithFreshnessCheck("💬 Developer Community Awareness (Hacker News)", hnResult, "hackernews", min_freshness_score),
|
|
347
|
+
sectionWithFreshnessCheck("📦 GitHub Repository Activity", repoResult, "reposearch", min_freshness_score),
|
|
348
|
+
sectionWithFreshnessCheck("🔄 Product Release Velocity (Changelog)", changelogResult, "changelog", min_freshness_score),
|
|
349
|
+
].filter(Boolean).join("\n\n");
|
|
271
350
|
return { content: [{ type: "text", text: combined }] };
|
|
272
351
|
});
|
|
273
352
|
// ─── Tool: extract_finance_landscape ─────────────────────────────────────────
|
|
@@ -282,43 +361,35 @@ server.registerTool("extract_finance_landscape", {
|
|
|
282
361
|
company_name: z.string().optional().describe("Company name for HN/Reddit/GitHub searches e.g. 'Palantir'. If omitted, derived from the ticker."),
|
|
283
362
|
github_query: z.string().optional().describe("GitHub search query or repo URL for the company's tech ecosystem e.g. 'palantir' or 'https://github.com/palantir/foundry'. If omitted, uses company_name."),
|
|
284
363
|
max_length: z.number().optional().default(12000),
|
|
364
|
+
min_freshness_score: z.number().optional().describe("Filter sections below this freshness_score (0–100). E.g. 70 = only recently retrieved data."),
|
|
285
365
|
}),
|
|
286
366
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
287
|
-
}, async ({ tickers, company_name, github_query, max_length }) => {
|
|
367
|
+
}, async ({ tickers, company_name, github_query, max_length, min_freshness_score }) => {
|
|
288
368
|
const perSection = Math.floor((max_length ?? 12000) / 5);
|
|
289
|
-
// Derive a search term: prefer explicit company_name, fall back to first ticker
|
|
290
369
|
const searchTerm = company_name ?? tickers.split(",")[0].trim();
|
|
291
370
|
const repoQuery = github_query ?? searchTerm;
|
|
292
|
-
// All five sources fire in parallel
|
|
293
371
|
const [priceResult, hnResult, redditResult, repoResult, changelogResult] = await Promise.allSettled([
|
|
294
|
-
// 1. The anchor: live price, market cap, P/E, 52w range
|
|
295
372
|
financeAdapter({ url: tickers, maxLength: perSection }),
|
|
296
|
-
// 2. Developer sentiment: what engineers think of this company's tech
|
|
297
373
|
hackerNewsAdapter({
|
|
298
374
|
url: `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(searchTerm)}&tags=story&hitsPerPage=10`,
|
|
299
375
|
maxLength: perSection,
|
|
300
376
|
}),
|
|
301
|
-
// 3. Broader community: investor and tech community discussion on Reddit
|
|
302
377
|
redditAdapter({ url: `https://www.reddit.com/search.json?q=${encodeURIComponent(searchTerm)}&sort=new&limit=15`, maxLength: perSection }),
|
|
303
|
-
// 4. Repo ecosystem: how many GitHub projects orbit this company's technology
|
|
304
378
|
repoSearchAdapter({ url: repoQuery, maxLength: perSection }),
|
|
305
|
-
// 5. Release velocity: is the company actually shipping product right now
|
|
306
379
|
changelogAdapter({ url: repoQuery, maxLength: perSection }),
|
|
307
380
|
]);
|
|
308
|
-
const section = (label, result) => result.status === "fulfilled"
|
|
309
|
-
? `## ${label}\n${result.value.raw}`
|
|
310
|
-
: `## ${label}\n[Unavailable: ${result.reason}]`;
|
|
311
381
|
const combined = [
|
|
312
382
|
`# Finance + Developer Intelligence: "${tickers}"${company_name ? ` (${company_name})` : ""}`,
|
|
313
383
|
`Generated: ${new Date().toISOString()}`,
|
|
314
384
|
`Sources: Yahoo Finance · Hacker News · Reddit · GitHub · Changelog`,
|
|
385
|
+
min_freshness_score ? `min_freshness_score: ${min_freshness_score}` : null,
|
|
315
386
|
"",
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
].join("\n\n");
|
|
387
|
+
sectionWithFreshnessCheck("📈 Market Data (Yahoo Finance)", priceResult, "finance", min_freshness_score),
|
|
388
|
+
sectionWithFreshnessCheck("💬 Developer Sentiment (Hacker News)", hnResult, "hackernews", min_freshness_score),
|
|
389
|
+
sectionWithFreshnessCheck("🗣️ Community Discussion (Reddit)", redditResult, "reddit", min_freshness_score),
|
|
390
|
+
sectionWithFreshnessCheck("📦 Repo Ecosystem (GitHub)", repoResult, "reposearch", min_freshness_score),
|
|
391
|
+
sectionWithFreshnessCheck("🔄 Product Release Velocity (Changelog)", changelogResult, "changelog", min_freshness_score),
|
|
392
|
+
].filter(Boolean).join("\n\n");
|
|
322
393
|
return { content: [{ type: "text", text: combined }] };
|
|
323
394
|
});
|
|
324
395
|
// ─── Tool: extract_sec_filings ─────────────────────────────────────────────
|
|
@@ -377,37 +448,31 @@ server.registerTool("extract_company_landscape", {
|
|
|
377
448
|
ticker: z.string().optional().describe("Stock ticker for finance data e.g. 'PLTR'. Leave blank for private companies."),
|
|
378
449
|
github_url: z.string().optional().describe("Optional GitHub repo or org URL e.g. 'https://github.com/palantir'. Improves changelog accuracy."),
|
|
379
450
|
max_length: z.number().optional().default(15000),
|
|
451
|
+
min_freshness_score: z.number().optional().describe("Filter sections below this freshness_score (0–100). E.g. 70 = only recently retrieved data."),
|
|
380
452
|
}),
|
|
381
453
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
382
|
-
}, async ({ company, ticker, github_url, max_length }) => {
|
|
454
|
+
}, async ({ company, ticker, github_url, max_length, min_freshness_score }) => {
|
|
383
455
|
const perSection = Math.floor((max_length ?? 15000) / 5);
|
|
384
456
|
const repoQuery = github_url ?? company;
|
|
385
457
|
const [secResult, contractsResult, gdeltResult, changelogResult, financeResult] = await Promise.allSettled([
|
|
386
|
-
// 1. What did they legally just disclose
|
|
387
458
|
secFilingsAdapter({ url: company, maxLength: perSection }),
|
|
388
|
-
// 2. Who is giving them government money
|
|
389
459
|
govContractsAdapter({ url: company, maxLength: perSection }),
|
|
390
|
-
// 3. What is global news saying right now
|
|
391
460
|
gdeltAdapter({ url: company, maxLength: perSection }),
|
|
392
|
-
// 4. Are they actually shipping product
|
|
393
461
|
changelogAdapter({ url: repoQuery, maxLength: perSection }),
|
|
394
|
-
// 5. What is the market pricing in
|
|
395
462
|
financeAdapter({ url: ticker ?? company, maxLength: perSection }),
|
|
396
463
|
]);
|
|
397
|
-
const section = (label, result) => result.status === "fulfilled"
|
|
398
|
-
? `## ${label}\n${result.value.raw}`
|
|
399
|
-
: `## ${label}\n[Unavailable: ${result.reason}]`;
|
|
400
464
|
const combined = [
|
|
401
465
|
`# Company Intelligence Landscape: "${company}"${ticker ? ` (${ticker})` : ""}`,
|
|
402
466
|
`Generated: ${new Date().toISOString()}`,
|
|
403
467
|
`Sources: SEC EDGAR · USASpending.gov · GDELT · Changelog · Yahoo Finance`,
|
|
468
|
+
min_freshness_score ? `min_freshness_score: ${min_freshness_score}` : null,
|
|
404
469
|
"",
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
].join("\n\n");
|
|
470
|
+
sectionWithFreshnessCheck("📋 SEC 8-K Filings — Legal Disclosures", secResult, "sec_filings", min_freshness_score),
|
|
471
|
+
sectionWithFreshnessCheck("🏛️ Federal Contract Awards (USASpending.gov)", contractsResult, "govcontracts", min_freshness_score),
|
|
472
|
+
sectionWithFreshnessCheck("🌍 Global News Intelligence (GDELT)", gdeltResult, "gdelt", min_freshness_score),
|
|
473
|
+
sectionWithFreshnessCheck("🔄 Product Release Velocity (Changelog)", changelogResult, "changelog", min_freshness_score),
|
|
474
|
+
sectionWithFreshnessCheck("📈 Market Data (Yahoo Finance)", financeResult, "finance", min_freshness_score),
|
|
475
|
+
].filter(Boolean).join("\n\n");
|
|
411
476
|
return { content: [{ type: "text", text: combined }] };
|
|
412
477
|
});
|
|
413
478
|
// ─── Tool: extract_gebiz ────────────────────────────────────────────────────
|
|
@@ -442,37 +507,30 @@ server.registerTool("extract_idea_landscape", {
|
|
|
442
507
|
inputSchema: z.object({
|
|
443
508
|
idea: z.string().describe("Your idea, problem space, or keyword. E.g. 'data freshness for AI agents', 'procurement intelligence', 'developer observability'"),
|
|
444
509
|
max_length: z.number().optional().default(14000),
|
|
510
|
+
min_freshness_score: z.number().optional().describe("Filter sections below this freshness_score (0–100). E.g. 70 = only recently retrieved data."),
|
|
445
511
|
}),
|
|
446
512
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
447
|
-
}, async ({ idea, max_length }) => {
|
|
513
|
+
}, async ({ idea, max_length, min_freshness_score }) => {
|
|
448
514
|
const perSection = Math.floor((max_length ?? 14000) / 6);
|
|
449
515
|
const [hnResult, ycResult, repoResult, jobsResult, pkgResult, phResult] = await Promise.allSettled([
|
|
450
|
-
// 1. Pain signal — what are developers actively complaining about
|
|
451
516
|
hackerNewsAdapter({
|
|
452
517
|
url: `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(idea)}&tags=story&hitsPerPage=10`,
|
|
453
518
|
maxLength: perSection,
|
|
454
519
|
}),
|
|
455
|
-
// 2. Funding signal — who has already raised money in this space
|
|
456
520
|
ycAdapter({
|
|
457
521
|
url: `https://www.ycombinator.com/companies?query=${encodeURIComponent(idea)}`,
|
|
458
522
|
maxLength: perSection,
|
|
459
523
|
}),
|
|
460
|
-
// 3. Crowding signal — how many GitHub repos exist, how active
|
|
461
524
|
repoSearchAdapter({ url: idea, maxLength: perSection }),
|
|
462
|
-
// 4. Market signal — companies paying salaries = real spend on this problem
|
|
463
525
|
jobsAdapter({ url: idea, maxLength: perSection }),
|
|
464
|
-
// 5. Ecosystem signal — npm/PyPI adoption and release velocity
|
|
465
526
|
packageTrendsAdapter({ url: idea, maxLength: perSection }),
|
|
466
|
-
// 6. Launch signal — what just shipped and how the market received it
|
|
467
527
|
productHuntAdapter({ url: idea, maxLength: perSection }),
|
|
468
528
|
]);
|
|
469
|
-
const section = (label, result) => result.status === "fulfilled"
|
|
470
|
-
? `## ${label}\n${result.value.raw}`
|
|
471
|
-
: `## ${label}\n[Unavailable: ${result.reason}]`;
|
|
472
529
|
const combined = [
|
|
473
530
|
`# Idea Validation Landscape: "${idea}"`,
|
|
474
531
|
`Generated: ${new Date().toISOString()}`,
|
|
475
532
|
`Sources: Hacker News · YC Companies · GitHub · Job Listings · npm/PyPI · Product Hunt`,
|
|
533
|
+
min_freshness_score ? `min_freshness_score: ${min_freshness_score}` : null,
|
|
476
534
|
"",
|
|
477
535
|
`## ℹ️ How to read this report`,
|
|
478
536
|
`Pain signal (HN): Are developers actively discussing this problem?`,
|
|
@@ -482,13 +540,13 @@ server.registerTool("extract_idea_landscape", {
|
|
|
482
540
|
`Ecosystem signal (npm/PyPI): Are packages being built and adopted?`,
|
|
483
541
|
`Launch signal (Product Hunt): What just shipped — community reception and timing.`,
|
|
484
542
|
"",
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
].join("\n\n");
|
|
543
|
+
sectionWithFreshnessCheck("🗣️ Pain Signal — Developer Discussions (Hacker News)", hnResult, "hackernews", min_freshness_score),
|
|
544
|
+
sectionWithFreshnessCheck("💰 Funding Signal — Backed Companies (YC)", ycResult, "yc", min_freshness_score),
|
|
545
|
+
sectionWithFreshnessCheck("📦 Crowding Signal — Open Source Landscape (GitHub)", repoResult, "reposearch", min_freshness_score),
|
|
546
|
+
sectionWithFreshnessCheck("💼 Market Signal — Hiring Activity (Job Listings)", jobsResult, "jobs", min_freshness_score),
|
|
547
|
+
sectionWithFreshnessCheck("🔧 Ecosystem Signal — Package Adoption (npm/PyPI)", pkgResult, "packagetrends", min_freshness_score),
|
|
548
|
+
sectionWithFreshnessCheck("🚀 Launch Signal — Recent Launches (Product Hunt)", phResult, "producthunt", min_freshness_score),
|
|
549
|
+
].filter(Boolean).join("\n\n");
|
|
492
550
|
return { content: [{ type: "text", text: combined }] };
|
|
493
551
|
});
|
|
494
552
|
// ─── Start ───────────────────────────────────────────────────────────────────
|
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
// ─── Decay rates per adapter ──────────────────────────────────────────────────
|
|
2
|
-
//
|
|
3
|
-
// Higher
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
changelog:
|
|
18
|
-
|
|
19
|
-
|
|
2
|
+
// Spec-compliant exponential DAR model.
|
|
3
|
+
// Higher lambda = data goes stale faster. Half-life formula: t½ = ln(2) / λ.
|
|
4
|
+
// Lambda is measured per hour and mirrors the Worker/D1 intelligence engine.
|
|
5
|
+
export const LAMBDA = {
|
|
6
|
+
hackernews: 0.050,
|
|
7
|
+
reddit: 0.010,
|
|
8
|
+
producthunt: 0.010,
|
|
9
|
+
jobs: 0.005,
|
|
10
|
+
finance: 0.001,
|
|
11
|
+
yc: 0.001,
|
|
12
|
+
packagetrends: 0.0005,
|
|
13
|
+
github: 0.0002,
|
|
14
|
+
reposearch: 0.0002,
|
|
15
|
+
google_scholar: 0.00005,
|
|
16
|
+
arxiv: 0.00005,
|
|
17
|
+
changelog: 0.0005,
|
|
18
|
+
gdelt: 0.020,
|
|
19
|
+
gebiz: 0.003,
|
|
20
|
+
govcontracts: 0.001,
|
|
21
|
+
sec_filings: 0.005,
|
|
22
|
+
landscape: 0.050,
|
|
23
|
+
gov_landscape: 0.001,
|
|
24
|
+
finance_landscape: 0.001,
|
|
25
|
+
company_landscape: 0.005,
|
|
26
|
+
idea_landscape: 0.050,
|
|
27
|
+
default: 0.001,
|
|
20
28
|
};
|
|
21
29
|
// ─── Score calculation ────────────────────────────────────────────────────────
|
|
22
30
|
// Returns null when content_date is unknown — we can't calculate age without a date.
|
|
23
|
-
// Returns 0
|
|
31
|
+
// Returns a clamped 0-100 exponential freshness score.
|
|
24
32
|
function calculateFreshnessScore(content_date, retrieved_at, adapter) {
|
|
25
33
|
if (!content_date)
|
|
26
34
|
return null;
|
|
@@ -29,9 +37,9 @@ function calculateFreshnessScore(content_date, retrieved_at, adapter) {
|
|
|
29
37
|
// Guard against unparseable dates
|
|
30
38
|
if (isNaN(published) || isNaN(retrieved))
|
|
31
39
|
return null;
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
return Math.max(0, Math.round(100 -
|
|
40
|
+
const hoursSinceRetrieved = Math.max(0, (retrieved - published) / (1000 * 60 * 60));
|
|
41
|
+
const lambda = LAMBDA[adapter] ?? LAMBDA.default;
|
|
42
|
+
return Math.max(0, Math.round(100 * Math.exp(-lambda * hoursSinceRetrieved)));
|
|
35
43
|
}
|
|
36
44
|
// ─── Score label ──────────────────────────────────────────────────────────────
|
|
37
45
|
// Human-readable interpretation alongside the number, per the spec.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* freshcontext-validate
|
|
5
|
+
* CLI validator for FreshContext-compatible responses
|
|
6
|
+
* Spec: https://github.com/PrinceGabriel-lgtm/freshcontext-mcp/blob/main/FRESHCONTEXT_SPEC.md
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node freshcontext-validate.js '<json_string>'
|
|
10
|
+
* node freshcontext-validate.js --stdin
|
|
11
|
+
* node freshcontext-validate.js --file <path>
|
|
12
|
+
* node freshcontext-validate.js --help
|
|
13
|
+
*
|
|
14
|
+
* Compliance levels:
|
|
15
|
+
* FreshContext-scored ★★★ — JSON form + numeric freshness_score
|
|
16
|
+
* FreshContext-compatible ★★ — JSON/envelope with retrieved_at + confidence
|
|
17
|
+
* FreshContext-aware ★ — retrieved_at only, no confidence
|
|
18
|
+
* FAIL — missing required fields
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const REQUIRED_JSON_FIELDS = ['source_url', 'retrieved_at', 'freshness_confidence', 'adapter'];
|
|
22
|
+
const VALID_CONFIDENCE = ['high', 'medium', 'low'];
|
|
23
|
+
const ENVELOPE_START = '[FRESHCONTEXT]';
|
|
24
|
+
const ENVELOPE_END = '[/FRESHCONTEXT]';
|
|
25
|
+
const REQUIRED_ENVELOPE_FIELDS = ['Source:', 'Published:', 'Retrieved:', 'Confidence:'];
|
|
26
|
+
|
|
27
|
+
const g = s => `\x1b[32m${s}\x1b[0m`;
|
|
28
|
+
const r = s => `\x1b[31m${s}\x1b[0m`;
|
|
29
|
+
const y = s => `\x1b[33m${s}\x1b[0m`;
|
|
30
|
+
const b = s => `\x1b[34m${s}\x1b[0m`;
|
|
31
|
+
const d = s => `\x1b[2m${s}\x1b[0m`;
|
|
32
|
+
const bold = s => `\x1b[1m${s}\x1b[0m`;
|
|
33
|
+
|
|
34
|
+
const pass = msg => ({ ok: true, warn: false, msg: ` ${g('✓')} ${msg}` });
|
|
35
|
+
const fail = msg => ({ ok: false, warn: false, msg: ` ${r('✕')} ${msg}` });
|
|
36
|
+
const warn = msg => ({ ok: true, warn: true, msg: ` ${y('!')} ${msg}` });
|
|
37
|
+
|
|
38
|
+
function validateJSON(obj) {
|
|
39
|
+
const results = [];
|
|
40
|
+
const fc = obj.freshcontext;
|
|
41
|
+
if (!fc) return [fail('No "freshcontext" key found in JSON response')];
|
|
42
|
+
|
|
43
|
+
for (const field of REQUIRED_JSON_FIELDS) {
|
|
44
|
+
if (fc[field] === undefined || fc[field] === null) {
|
|
45
|
+
results.push(fail(`Missing required field: freshcontext.${field}`));
|
|
46
|
+
} else {
|
|
47
|
+
results.push(pass(`freshcontext.${field} = ${d(String(fc[field]).slice(0, 80))}`));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (fc.retrieved_at) {
|
|
52
|
+
const dt = new Date(fc.retrieved_at);
|
|
53
|
+
if (isNaN(dt.getTime())) {
|
|
54
|
+
results.push(fail(`retrieved_at is not valid ISO 8601: "${fc.retrieved_at}"`));
|
|
55
|
+
} else {
|
|
56
|
+
results.push(pass(`retrieved_at is valid ISO 8601`));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (fc.freshness_confidence && !VALID_CONFIDENCE.includes(fc.freshness_confidence)) {
|
|
61
|
+
results.push(fail(`freshness_confidence must be high, medium, or low. Got: "${fc.freshness_confidence}"`));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (fc.freshness_score !== undefined && fc.freshness_score !== null) {
|
|
65
|
+
if (typeof fc.freshness_score !== 'number' || fc.freshness_score < 0 || fc.freshness_score > 100) {
|
|
66
|
+
results.push(fail(`freshness_score must be 0-100. Got: ${fc.freshness_score}`));
|
|
67
|
+
} else {
|
|
68
|
+
const col = fc.freshness_score >= 70 ? g : fc.freshness_score >= 50 ? y : r;
|
|
69
|
+
results.push(pass(`freshness_score: ${col(fc.freshness_score + '/100')}`));
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
results.push(warn('freshness_score not present (optional — required for ★★★ level)'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return results;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validateEnvelope(text) {
|
|
79
|
+
const results = [];
|
|
80
|
+
if (!text.includes(ENVELOPE_START)) return [fail(`Missing opening tag: ${ENVELOPE_START}`)];
|
|
81
|
+
if (!text.includes(ENVELOPE_END)) return [fail(`Missing closing tag: ${ENVELOPE_END}`)];
|
|
82
|
+
results.push(pass('Envelope tags present'));
|
|
83
|
+
|
|
84
|
+
const start = text.indexOf(ENVELOPE_START) + ENVELOPE_START.length;
|
|
85
|
+
const end = text.indexOf(ENVELOPE_END);
|
|
86
|
+
const envelope = text.slice(start, end);
|
|
87
|
+
|
|
88
|
+
for (const field of REQUIRED_ENVELOPE_FIELDS) {
|
|
89
|
+
if (!envelope.includes(field)) {
|
|
90
|
+
results.push(fail(`Missing field: ${field}`));
|
|
91
|
+
} else {
|
|
92
|
+
const line = envelope.split('\n').find(l => l.trim().startsWith(field));
|
|
93
|
+
results.push(pass(`${field.replace(':', '')} — ${d((line || '').trim().slice(0, 70))}`));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const confLine = envelope.split('\n').find(l => l.trim().startsWith('Confidence:'));
|
|
98
|
+
if (confLine) {
|
|
99
|
+
const confVal = confLine.split(':')[1]?.trim().toLowerCase();
|
|
100
|
+
if (!VALID_CONFIDENCE.includes(confVal)) {
|
|
101
|
+
results.push(fail(`Confidence must be high, medium, or low. Got: "${confVal}"`));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function complianceLevel(results, hasScore, mode) {
|
|
109
|
+
const failed = results.filter(res => !res.ok).length;
|
|
110
|
+
if (failed > 0) return { level: 'FAIL', colour: r };
|
|
111
|
+
if (mode === 'json' && hasScore) return { level: 'FreshContext-scored \u2605\u2605\u2605', colour: g };
|
|
112
|
+
return { level: 'FreshContext-compatible \u2605\u2605', colour: g };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateString(input, label) {
|
|
116
|
+
console.log(`\n${bold('freshcontext-validate')} ${d('v1.0.0')}`);
|
|
117
|
+
console.log(d('\u2500'.repeat(52)));
|
|
118
|
+
console.log(`${b('Input:')} ${d(label)}\n`);
|
|
119
|
+
|
|
120
|
+
let results = [];
|
|
121
|
+
let hasScore = false;
|
|
122
|
+
let mode = 'envelope';
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(input);
|
|
126
|
+
mode = 'json';
|
|
127
|
+
console.log(`${d('Mode:')} ${b('JSON structured form')}\n`);
|
|
128
|
+
results = validateJSON(parsed);
|
|
129
|
+
hasScore = parsed?.freshcontext?.freshness_score !== undefined
|
|
130
|
+
&& parsed?.freshcontext?.freshness_score !== null;
|
|
131
|
+
} catch {
|
|
132
|
+
if (input.includes(ENVELOPE_START)) {
|
|
133
|
+
console.log(`${d('Mode:')} ${b('Text envelope')}\n`);
|
|
134
|
+
results = validateEnvelope(input);
|
|
135
|
+
} else {
|
|
136
|
+
console.log(r('✕ Input is neither valid JSON nor a FreshContext text envelope.\n'));
|
|
137
|
+
console.log(d('Expected:'));
|
|
138
|
+
console.log(` ${d('JSON:')} {"freshcontext": {"source_url": "...", "retrieved_at": "...", ...}}`);
|
|
139
|
+
console.log(` ${d('Envelope:')} [FRESHCONTEXT]\\nSource: ...\\n[/FRESHCONTEXT]`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
results.forEach(res => console.log(res.msg));
|
|
145
|
+
|
|
146
|
+
const passed = results.filter(res => res.ok && !res.warn).length;
|
|
147
|
+
const failed = results.filter(res => !res.ok).length;
|
|
148
|
+
const warned = results.filter(res => res.warn).length;
|
|
149
|
+
|
|
150
|
+
const { level, colour } = complianceLevel(results, hasScore, mode);
|
|
151
|
+
|
|
152
|
+
console.log(`\n${d('\u2500'.repeat(52))}`);
|
|
153
|
+
console.log(`${d('Checks:')} ${g(passed + ' passed')}${warned ? ', ' + y(warned + ' warnings') : ''}${failed ? ', ' + r(failed + ' failed') : ''}`);
|
|
154
|
+
console.log(`${d('Result:')} ${colour(bold(level))}\n`);
|
|
155
|
+
|
|
156
|
+
if (failed > 0) process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const args = process.argv.slice(2);
|
|
160
|
+
|
|
161
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
162
|
+
console.log(`
|
|
163
|
+
${bold('freshcontext-validate')} ${d('v1.0.0')}
|
|
164
|
+
Validates FreshContext-compatible responses against the spec.
|
|
165
|
+
|
|
166
|
+
${bold('USAGE')}
|
|
167
|
+
node freshcontext-validate.js ${d('<json_or_envelope_string>')}
|
|
168
|
+
node freshcontext-validate.js --file ${d('<path>')}
|
|
169
|
+
node freshcontext-validate.js --stdin
|
|
170
|
+
echo '...' | node freshcontext-validate.js --stdin
|
|
171
|
+
|
|
172
|
+
${bold('COMPLIANCE LEVELS')}
|
|
173
|
+
${g('FreshContext-scored \u2605\u2605\u2605')} Full JSON form + numeric freshness_score
|
|
174
|
+
${g('FreshContext-compatible \u2605\u2605')} JSON/envelope with retrieved_at + confidence
|
|
175
|
+
${r('FAIL')} Missing required fields
|
|
176
|
+
|
|
177
|
+
${bold('SPEC')}
|
|
178
|
+
https://freshcontext-site.pages.dev/spec.html
|
|
179
|
+
https://github.com/PrinceGabriel-lgtm/freshcontext-mcp/blob/main/FRESHCONTEXT_SPEC.md
|
|
180
|
+
`);
|
|
181
|
+
process.exit(0);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (args[0] === '--stdin') {
|
|
185
|
+
let data = '';
|
|
186
|
+
process.stdin.setEncoding('utf8');
|
|
187
|
+
process.stdin.on('data', chunk => data += chunk);
|
|
188
|
+
process.stdin.on('end', () => validateString(data.trim(), 'stdin'));
|
|
189
|
+
} else if (args[0] === '--file') {
|
|
190
|
+
const path = args[1];
|
|
191
|
+
if (!path) { console.error(r('--file requires a path argument')); process.exit(1); }
|
|
192
|
+
const fs = require('fs');
|
|
193
|
+
validateString(fs.readFileSync(path, 'utf8').trim(), path);
|
|
194
|
+
} else {
|
|
195
|
+
validateString(args[0], 'inline input');
|
|
196
|
+
}
|