@wangjunjian/dsh-github-trending 0.1.0 → 0.2.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/README.md +23 -0
- package/README.zh.md +21 -0
- package/lib/cache.js +30 -1
- package/lib/client.js +230 -21
- package/lib/index.js +140 -5
- package/lib/intros.js +131 -0
- package/lib/summaries.js +352 -0
- package/lib/types/cache.d.ts +16 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/index.d.ts +22 -0
- package/lib/types/intros.d.ts +65 -0
- package/lib/types/parser.d.ts +2 -0
- package/lib/types/summaries.d.ts +174 -0
- package/package.json +6 -1
package/lib/index.js
CHANGED
|
@@ -11,19 +11,32 @@
|
|
|
11
11
|
import z from '@deepseek-ai/schemastery';
|
|
12
12
|
import { TrendingCache } from './cache.js';
|
|
13
13
|
import { DEFAULT_REFRESH_INTERVAL_MS } from './constants.js';
|
|
14
|
+
import { IntroductionGenerator } from './intros.js';
|
|
15
|
+
import { OverviewGenerator } from './summaries.js';
|
|
14
16
|
import { applyGithubTrendingTool, buildTrendingUrl, fetchTrendingRepositories, MAX_RESULTS_LIMIT } from './tool.js';
|
|
15
17
|
export { DEFAULT_REFRESH_INTERVAL_MS } from './constants.js';
|
|
16
18
|
export { DEFAULT_MAX_RESULTS, MAX_RESULTS_LIMIT } from './tool.js';
|
|
17
19
|
/** Cordis plugin name used by loader diagnostics. */
|
|
18
20
|
export const name = 'github-trending';
|
|
21
|
+
/** How long a `?wait=1` request is held while waiting for overview progress. */
|
|
22
|
+
const OVERVIEW_WAIT_TIMEOUT_MS = 25_000;
|
|
19
23
|
/** Services this plugin requires. */
|
|
20
|
-
export const inject = ['tools', 'systemPrompt', 'webServer'];
|
|
24
|
+
export const inject = ['tools', 'systemPrompt', 'webServer', 'llm', 'agentDefaultModel'];
|
|
21
25
|
/** Schemastery config schema with defaults and bounds. */
|
|
22
26
|
export const Config = z.object({
|
|
23
27
|
enabled: z.boolean().default(true),
|
|
24
28
|
timeoutMs: z.number().default(30_000),
|
|
25
29
|
maxResults: z.number().default(10),
|
|
26
30
|
refreshIntervalMs: z.number().default(DEFAULT_REFRESH_INTERVAL_MS),
|
|
31
|
+
overviewsEnabled: z.boolean().default(false),
|
|
32
|
+
overviewsLanguage: z.string().default('zh'),
|
|
33
|
+
overviewsMaxRepos: z.number().default(10),
|
|
34
|
+
overviewsProvider: z.string(),
|
|
35
|
+
overviewsModel: z.string(),
|
|
36
|
+
overviewsMaxTokens: z.number().default(2048),
|
|
37
|
+
overviewsTimeoutMs: z.number().default(60_000),
|
|
38
|
+
introsMaxTokens: z.number().default(4096),
|
|
39
|
+
introsTimeoutMs: z.number().default(120_000),
|
|
27
40
|
});
|
|
28
41
|
/**
|
|
29
42
|
* Assert that a numeric config value is a positive finite integer.
|
|
@@ -47,15 +60,56 @@ export function apply(ctx, config) {
|
|
|
47
60
|
assertPositiveInteger('timeoutMs', resolved.timeoutMs);
|
|
48
61
|
assertPositiveInteger('maxResults', resolved.maxResults);
|
|
49
62
|
assertPositiveInteger('refreshIntervalMs', resolved.refreshIntervalMs);
|
|
63
|
+
assertPositiveInteger('overviewsMaxRepos', resolved.overviewsMaxRepos);
|
|
64
|
+
assertPositiveInteger('overviewsMaxTokens', resolved.overviewsMaxTokens);
|
|
65
|
+
assertPositiveInteger('overviewsTimeoutMs', resolved.overviewsTimeoutMs);
|
|
66
|
+
assertPositiveInteger('introsMaxTokens', resolved.introsMaxTokens);
|
|
67
|
+
assertPositiveInteger('introsTimeoutMs', resolved.introsTimeoutMs);
|
|
68
|
+
if (resolved.overviewsLanguage !== 'zh' && resolved.overviewsLanguage !== 'en') {
|
|
69
|
+
throw new Error(`github-trending: overviewsLanguage must be "zh" or "en"`);
|
|
70
|
+
}
|
|
71
|
+
const hasProviderOverride = resolved.overviewsProvider !== undefined && resolved.overviewsProvider !== '';
|
|
72
|
+
const hasModelOverride = resolved.overviewsModel !== undefined && resolved.overviewsModel !== '';
|
|
73
|
+
if (hasProviderOverride !== hasModelOverride) {
|
|
74
|
+
throw new Error('github-trending: overviewsProvider and overviewsModel must be configured together');
|
|
75
|
+
}
|
|
50
76
|
if (!resolved.enabled)
|
|
51
77
|
return;
|
|
52
78
|
applyGithubTrendingTool(ctx, {
|
|
53
79
|
timeoutMs: resolved.timeoutMs,
|
|
54
80
|
maxResults: Math.min(resolved.maxResults, MAX_RESULTS_LIMIT),
|
|
55
81
|
});
|
|
82
|
+
let generator;
|
|
83
|
+
let introGenerator;
|
|
84
|
+
let onRefreshed;
|
|
85
|
+
if (resolved.overviewsEnabled) {
|
|
86
|
+
// Resolve the route lazily: currentSelection() is live-read, and the
|
|
87
|
+
// settings layer may not even be mounted yet when this plugin loads, so
|
|
88
|
+
// capturing the selection once here could freeze a stale fallback model.
|
|
89
|
+
const resolveRoute = () => hasProviderOverride
|
|
90
|
+
? { provider: resolved.overviewsProvider, model: resolved.overviewsModel }
|
|
91
|
+
: ctx.agentDefaultModel.currentSelection();
|
|
92
|
+
generator = new OverviewGenerator(ctx, resolveRoute, {
|
|
93
|
+
language: resolved.overviewsLanguage,
|
|
94
|
+
maxRepos: resolved.overviewsMaxRepos,
|
|
95
|
+
maxTokens: resolved.overviewsMaxTokens,
|
|
96
|
+
timeoutMs: resolved.overviewsTimeoutMs,
|
|
97
|
+
});
|
|
98
|
+
introGenerator = new IntroductionGenerator(ctx, resolveRoute, {
|
|
99
|
+
language: resolved.overviewsLanguage,
|
|
100
|
+
maxTokens: resolved.introsMaxTokens,
|
|
101
|
+
timeoutMs: resolved.introsTimeoutMs,
|
|
102
|
+
});
|
|
103
|
+
onRefreshed = (entry, language, since) => {
|
|
104
|
+
// Fire-and-forget: overview generation never blocks the refresh path;
|
|
105
|
+
// the panel picks up overviews on its next fetch or poll.
|
|
106
|
+
generator?.request(entry, language, since);
|
|
107
|
+
};
|
|
108
|
+
}
|
|
56
109
|
const cache = new TrendingCache({
|
|
57
110
|
intervalMs: resolved.refreshIntervalMs,
|
|
58
111
|
timeoutMs: resolved.timeoutMs,
|
|
112
|
+
onRefreshed,
|
|
59
113
|
});
|
|
60
114
|
ctx.effect(() => () => {
|
|
61
115
|
cache.dispose();
|
|
@@ -72,7 +126,7 @@ export function apply(ctx, config) {
|
|
|
72
126
|
ctx.effect(() => ctx.webServer.register({
|
|
73
127
|
kind: 'prefix',
|
|
74
128
|
path: '/github-trending',
|
|
75
|
-
handler: createTrendingHandler(cache),
|
|
129
|
+
handler: createTrendingHandler(cache, generator, introGenerator, resolved.overviewsMaxRepos),
|
|
76
130
|
}), 'github-trending: web route');
|
|
77
131
|
}
|
|
78
132
|
/**
|
|
@@ -80,10 +134,55 @@ export function apply(ctx, config) {
|
|
|
80
134
|
* data to the browser side. Runs from the host so it is not subject to browser
|
|
81
135
|
* CORS restrictions. Supports `?refresh=1` to force a host-side refetch.
|
|
82
136
|
*
|
|
137
|
+
* Each request also tells the overview generator which window the panel is
|
|
138
|
+
* looking at, and annotates top repositories still waiting for an overview
|
|
139
|
+
* with `overviewPending: true` (plus `overviewGenerating: true` on the one
|
|
140
|
+
* currently in flight) so the panel can show a generating indicator.
|
|
141
|
+
* Repositories whose generation already failed are left unflagged and instead
|
|
142
|
+
* carry `overviewError` with the reason, so the panel does not wait on them
|
|
143
|
+
* forever and the failure stays diagnosable.
|
|
144
|
+
*
|
|
145
|
+
* `?wait=1` turns the request into a long-poll: when the entry still has
|
|
146
|
+
* pending overviews, the response is held until the generator makes progress
|
|
147
|
+
* (or a timeout), so the panel sees each overview the moment it lands.
|
|
148
|
+
*
|
|
149
|
+
* `?intro=owner/name` instead serves a full Markdown project introduction,
|
|
150
|
+
* generated on demand (cached afterwards) — this is what the panel's overview
|
|
151
|
+
* click opens.
|
|
152
|
+
*
|
|
83
153
|
* @param cache - shared trending cache.
|
|
154
|
+
* @param generator - overview scheduler, undefined when overviews are disabled.
|
|
155
|
+
* @param introGenerator - on-demand introduction generator, same gating.
|
|
156
|
+
* @param overviewsMaxRepos - only the top N repositories are overview-eligible.
|
|
84
157
|
* @returns the request handler.
|
|
85
158
|
*/
|
|
86
|
-
function createTrendingHandler(cache) {
|
|
159
|
+
function createTrendingHandler(cache, generator, introGenerator, overviewsMaxRepos) {
|
|
160
|
+
/** Whether the entry still has overview-eligible repositories worth waiting for. */
|
|
161
|
+
function hasPending(entry) {
|
|
162
|
+
if (generator === undefined)
|
|
163
|
+
return false;
|
|
164
|
+
return entry.repositories.some((repo, index) => index < overviewsMaxRepos &&
|
|
165
|
+
repo.overview === undefined &&
|
|
166
|
+
generator.failureReason(entry, repo.fullName) === undefined);
|
|
167
|
+
}
|
|
168
|
+
/** Attach overviewPending / overviewGenerating / overviewError fields when generation is active. */
|
|
169
|
+
function present(entry) {
|
|
170
|
+
if (generator === undefined)
|
|
171
|
+
return JSON.stringify(entry);
|
|
172
|
+
const currentFullName = generator.currentFullName;
|
|
173
|
+
const repositories = entry.repositories.map((repo, index) => {
|
|
174
|
+
if (index >= overviewsMaxRepos || repo.overview !== undefined)
|
|
175
|
+
return repo;
|
|
176
|
+
const failure = generator.failureReason(entry, repo.fullName);
|
|
177
|
+
if (failure !== undefined)
|
|
178
|
+
return { ...repo, overviewError: failure };
|
|
179
|
+
const flags = { overviewPending: true };
|
|
180
|
+
if (repo.fullName === currentFullName)
|
|
181
|
+
flags.overviewGenerating = true;
|
|
182
|
+
return { ...repo, ...flags };
|
|
183
|
+
});
|
|
184
|
+
return JSON.stringify({ ...entry, repositories });
|
|
185
|
+
}
|
|
87
186
|
return async (req, res) => {
|
|
88
187
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
89
188
|
res.writeHead(405, { 'content-type': 'application/json' });
|
|
@@ -91,10 +190,36 @@ function createTrendingHandler(cache) {
|
|
|
91
190
|
return;
|
|
92
191
|
}
|
|
93
192
|
const url = new URL(req.url ?? '/', 'http://x');
|
|
193
|
+
// On-demand full project introduction: `?intro=owner/name`.
|
|
194
|
+
const introFullName = url.searchParams.get('intro');
|
|
195
|
+
if (introFullName !== null) {
|
|
196
|
+
if (introGenerator === undefined) {
|
|
197
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
198
|
+
res.end(JSON.stringify({ error: 'introductions are disabled (overviewsEnabled is off)' }));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(introFullName)) {
|
|
202
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
203
|
+
res.end(JSON.stringify({ error: 'intro must be an owner/name repository path' }));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const introduction = await introGenerator.get(introFullName, cache.findRepository(introFullName));
|
|
208
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' });
|
|
209
|
+
res.end(JSON.stringify({ fullName: introFullName, introduction }));
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
213
|
+
res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' });
|
|
214
|
+
res.end(JSON.stringify({ error: message }));
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
94
218
|
const language = url.searchParams.get('language') ?? undefined;
|
|
95
219
|
const sinceRaw = url.searchParams.get('since') ?? 'daily';
|
|
96
220
|
const since = sinceRaw === 'weekly' || sinceRaw === 'monthly' ? sinceRaw : 'daily';
|
|
97
221
|
const forceRefresh = url.searchParams.get('refresh') === '1';
|
|
222
|
+
generator?.setActiveSince(since);
|
|
98
223
|
try {
|
|
99
224
|
const fetchers = {
|
|
100
225
|
daily: async (signal) => {
|
|
@@ -121,11 +246,21 @@ function createTrendingHandler(cache) {
|
|
|
121
246
|
else {
|
|
122
247
|
entry = cache.get(language, since);
|
|
123
248
|
}
|
|
249
|
+
// Long-poll: while overviews are pending, hold the request until the
|
|
250
|
+
// generator finishes (or starts) the next repository, so the panel
|
|
251
|
+
// updates the moment each overview lands instead of on a fixed poll
|
|
252
|
+
// interval. Re-read the cache afterwards: a refresh may have replaced
|
|
253
|
+
// the entry during the wait.
|
|
254
|
+
const waitForProgress = req.method === 'GET' && url.searchParams.get('wait') === '1';
|
|
255
|
+
if (waitForProgress && generator !== undefined && hasPending(entry)) {
|
|
256
|
+
await generator.waitForChange(OVERVIEW_WAIT_TIMEOUT_MS);
|
|
257
|
+
entry = cache.get(language, since) ?? entry;
|
|
258
|
+
}
|
|
124
259
|
res.writeHead(200, {
|
|
125
260
|
'content-type': 'application/json; charset=utf-8',
|
|
126
261
|
'cache-control': 'no-cache',
|
|
127
262
|
});
|
|
128
|
-
res.end(
|
|
263
|
+
res.end(present(entry));
|
|
129
264
|
}
|
|
130
265
|
catch (error) {
|
|
131
266
|
const cached = cache.get(language, since);
|
|
@@ -135,7 +270,7 @@ function createTrendingHandler(cache) {
|
|
|
135
270
|
'content-type': 'application/json; charset=utf-8',
|
|
136
271
|
'cache-control': 'no-cache',
|
|
137
272
|
});
|
|
138
|
-
res.end(
|
|
273
|
+
res.end(present(cached));
|
|
139
274
|
return;
|
|
140
275
|
}
|
|
141
276
|
const message = error instanceof Error ? error.message : String(error);
|
package/lib/intros.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-demand, README-backed project introductions generated through the harness
|
|
3
|
+
* LLM service.
|
|
4
|
+
*
|
|
5
|
+
* Unlike the automatic per-refresh overviews, a full introduction is generated
|
|
6
|
+
* only when the user clicks a repository's overview area in the panel
|
|
7
|
+
* (`GET /github-trending?intro=owner/name`), then cached in memory for the
|
|
8
|
+
* lifetime of the host process. Concurrent requests for the same repository
|
|
9
|
+
* share one in-flight generation.
|
|
10
|
+
*
|
|
11
|
+
* @module @wangjunjian/dsh-github-trending/intros
|
|
12
|
+
*/
|
|
13
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
14
|
+
import { fetchReadmeText, LANGUAGE_NAMES, streamGeneratedText } from './summaries.js';
|
|
15
|
+
/** Fixed section template, per output language, that every introduction follows. */
|
|
16
|
+
const SECTION_TEMPLATE = {
|
|
17
|
+
zh: `## <项目名>
|
|
18
|
+
|
|
19
|
+
#### 核心定位
|
|
20
|
+
面向人群、核心痛点与差异化核心价值;2-3 句。
|
|
21
|
+
|
|
22
|
+
#### 应用场景
|
|
23
|
+
正向落地场景 + 反向禁忌场景,覆盖适用与不适用边界;2-4 句。
|
|
24
|
+
|
|
25
|
+
#### 许可证协议
|
|
26
|
+
开源授权说明、商用权限界定、合规与风险评估;1-3 句。README 未提及许可证时,明确写出"仓库未声明开源许可证"。`,
|
|
27
|
+
en: `## <project name>
|
|
28
|
+
|
|
29
|
+
#### Core positioning
|
|
30
|
+
Target audience, core pain point, and differentiated core value; 2-3 sentences.
|
|
31
|
+
|
|
32
|
+
#### Use cases
|
|
33
|
+
Positive fit scenarios plus counter-indications, covering where it applies and where it does not; 2-4 sentences.
|
|
34
|
+
|
|
35
|
+
#### License
|
|
36
|
+
Open-source license terms, commercial-use permissions, compliance and risk assessment; 1-3 sentences. State explicitly when the README declares no license.`,
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Generate a structured Markdown introduction for a repository via the harness
|
|
40
|
+
* LLM service.
|
|
41
|
+
*
|
|
42
|
+
* @param ctx - host context providing the `llm` service.
|
|
43
|
+
* @param route - provider/model route for the call.
|
|
44
|
+
* @param repo - trending metadata when the repository is in the cache.
|
|
45
|
+
* @param fullName - `owner/name` repository path.
|
|
46
|
+
* @param readme - truncated README text.
|
|
47
|
+
* @param options - resolved introduction options.
|
|
48
|
+
* @returns the Markdown introduction.
|
|
49
|
+
*/
|
|
50
|
+
export async function generateIntroduction(ctx, route, repo, fullName, readme, options) {
|
|
51
|
+
const languageName = LANGUAGE_NAMES[options.language];
|
|
52
|
+
const system = `You write clear, uniformly structured introductions of GitHub repositories for a trending list. Write in ${languageName}. Output Markdown following EXACTLY this section template, in this order, keeping the headings verbatim:
|
|
53
|
+
|
|
54
|
+
${SECTION_TEMPLATE[options.language]}
|
|
55
|
+
|
|
56
|
+
Rules: base everything strictly on the README and metadata given by the user; never invent features, version numbers, or commands that are not in the README; keep the whole introduction under 400 words; output nothing outside this structure.`;
|
|
57
|
+
const metadata = repo === undefined
|
|
58
|
+
? `Repository: ${fullName}`
|
|
59
|
+
: `Repository: ${repo.fullName}\nDescription: ${repo.description ?? '(none)'}\nLanguage: ${repo.language ?? '(unknown)'}\nStars: ${repo.stars}\nForks: ${repo.forks}\nStars gained in the current window: ${repo.starsToday}`;
|
|
60
|
+
const messages = [
|
|
61
|
+
createUserMessage({
|
|
62
|
+
content: [{ type: 'text', text: `${metadata}\n\nREADME (truncated):\n${readme}` }],
|
|
63
|
+
source: { kind: 'plugin', plugin: 'github-trending' },
|
|
64
|
+
}),
|
|
65
|
+
];
|
|
66
|
+
return streamGeneratedText(ctx, route, {
|
|
67
|
+
messages,
|
|
68
|
+
system,
|
|
69
|
+
maxTokens: options.maxTokens,
|
|
70
|
+
timeoutMs: options.timeoutMs,
|
|
71
|
+
label: `introduction generation for ${fullName}`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* On-demand introduction generator with in-memory caching and in-flight
|
|
76
|
+
* deduplication.
|
|
77
|
+
*
|
|
78
|
+
* Results are cached per `language:fullName` for the lifetime of the host
|
|
79
|
+
* process — introductions are prose about a repository's README, which rarely
|
|
80
|
+
* changes within a session. Failures are never cached, so the next click
|
|
81
|
+
* retries.
|
|
82
|
+
*/
|
|
83
|
+
export class IntroductionGenerator {
|
|
84
|
+
ctx;
|
|
85
|
+
resolveRoute;
|
|
86
|
+
options;
|
|
87
|
+
cache = new Map();
|
|
88
|
+
inflight = new Map();
|
|
89
|
+
constructor(ctx, resolveRoute, options) {
|
|
90
|
+
this.ctx = ctx;
|
|
91
|
+
this.resolveRoute = resolveRoute;
|
|
92
|
+
this.options = options;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Get the introduction for a repository, generating it on first request.
|
|
96
|
+
*
|
|
97
|
+
* @param fullName - `owner/name` repository path.
|
|
98
|
+
* @param repo - trending metadata when available in the cache.
|
|
99
|
+
* @returns the Markdown introduction.
|
|
100
|
+
* @throws when the README is unavailable or the LLM call fails.
|
|
101
|
+
*/
|
|
102
|
+
async get(fullName, repo) {
|
|
103
|
+
const key = `${this.options.language}:${fullName}`;
|
|
104
|
+
const cached = this.cache.get(key);
|
|
105
|
+
if (cached !== undefined)
|
|
106
|
+
return cached;
|
|
107
|
+
const existing = this.inflight.get(key);
|
|
108
|
+
if (existing !== undefined)
|
|
109
|
+
return existing;
|
|
110
|
+
const promise = this.generate(fullName, repo)
|
|
111
|
+
.then((text) => {
|
|
112
|
+
this.cache.set(key, text);
|
|
113
|
+
return text;
|
|
114
|
+
})
|
|
115
|
+
.finally(() => {
|
|
116
|
+
this.inflight.delete(key);
|
|
117
|
+
});
|
|
118
|
+
this.inflight.set(key, promise);
|
|
119
|
+
return promise;
|
|
120
|
+
}
|
|
121
|
+
async generate(fullName, repo) {
|
|
122
|
+
const readme = await fetchReadmeText(fullName);
|
|
123
|
+
if (readme === undefined) {
|
|
124
|
+
throw new Error(`introduction generation for ${fullName}: no README found on the default branch`);
|
|
125
|
+
}
|
|
126
|
+
// Resolve the route per generation so the current default-model selection
|
|
127
|
+
// always applies, same as overview generation.
|
|
128
|
+
return generateIntroduction(this.ctx, this.resolveRoute(), repo, fullName, readme, this.options);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=intros.js.map
|