dsh-plugin-discovery 1.0.0 → 1.1.1

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/index.js CHANGED
@@ -1,10 +1,219 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
1
4
  import z from "@deepseek-ai/schemastery";
2
5
  import { Service } from "@deepseek-ai/cordis";
3
6
  import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
4
7
  import { defineTool } from "@deepseek-ai/dsh-tools";
5
8
  import { markRemoteMethod } from "./mark-remote.js";
6
9
 
7
- const USER_AGENT = "dsh-plugin-discovery/1.0";
10
+ const USER_AGENT = "dsh-plugin-discovery/1.1";
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+
13
+ const DEFAULT_CATALOG_GITEE_URL =
14
+ "https://gitee.com/hjhcos/dsh-plugin-discovery/raw/main/data/recommended.json";
15
+ const DEFAULT_CATALOG_GITHUB_URL =
16
+ "https://raw.githubusercontent.com/hjhcos/dsh-plugin-discovery/main/data/recommended.json";
17
+
18
+ function emptyCatalog(error) {
19
+ return {
20
+ version: 0,
21
+ updatedAt: "",
22
+ categories: [],
23
+ plugins: [],
24
+ origin: "none",
25
+ error: error ? String(error) : undefined,
26
+ };
27
+ }
28
+
29
+ function normalizeCatalog(data, origin = "bundled") {
30
+ if (!data || typeof data !== "object") return emptyCatalog("invalid catalog");
31
+ return {
32
+ version: Number(data.version) || 0,
33
+ updatedAt: data.updatedAt || "",
34
+ categories: Array.isArray(data.categories) ? data.categories : [],
35
+ plugins: Array.isArray(data.plugins) ? data.plugins : [],
36
+ origin,
37
+ };
38
+ }
39
+
40
+ function loadBundledCatalog() {
41
+ try {
42
+ const raw = readFileSync(join(__dirname, "data", "recommended.json"), "utf8");
43
+ return normalizeCatalog(JSON.parse(raw), "bundled");
44
+ } catch (error) {
45
+ return emptyCatalog(error?.message || error);
46
+ }
47
+ }
48
+
49
+ function resolveCatalogCachePath() {
50
+ const home = String(process.env.DSH_HOME || "").trim();
51
+ const base = home || join(__dirname, ".cache");
52
+ return join(base, "cache", "dsh-plugin-discovery", "recommended.json");
53
+ }
54
+
55
+ function loadCachedCatalog() {
56
+ try {
57
+ const raw = readFileSync(resolveCatalogCachePath(), "utf8");
58
+ return normalizeCatalog(JSON.parse(raw), "cache");
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function saveCachedCatalog(catalog) {
65
+ const path = resolveCatalogCachePath();
66
+ mkdirSync(dirname(path), { recursive: true });
67
+ const payload = {
68
+ version: catalog.version,
69
+ updatedAt: catalog.updatedAt,
70
+ note: catalog.note,
71
+ categories: catalog.categories,
72
+ plugins: catalog.plugins,
73
+ _cachedAt: new Date().toISOString(),
74
+ _origin: catalog.origin,
75
+ };
76
+ writeFileSync(path, JSON.stringify(payload, null, 2), "utf8");
77
+ }
78
+
79
+ function pickNewerCatalog(...candidates) {
80
+ let best = null;
81
+ for (const row of candidates) {
82
+ if (!row || !Array.isArray(row.plugins)) continue;
83
+ if (!best || (row.version || 0) > (best.version || 0)) best = row;
84
+ else if ((row.version || 0) === (best.version || 0) && row.origin !== "bundled" && best.origin === "bundled") {
85
+ best = row;
86
+ }
87
+ }
88
+ return best || emptyCatalog();
89
+ }
90
+
91
+ async function fetchCatalogFromUrl(url, timeoutMs = 12000) {
92
+ const trimmed = String(url || "").trim();
93
+ if (!trimmed) return null;
94
+ const controller = new AbortController();
95
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
96
+ try {
97
+ const response = await fetch(trimmed, {
98
+ headers: {
99
+ Accept: "application/json,text/plain,*/*",
100
+ "User-Agent": USER_AGENT,
101
+ },
102
+ signal: controller.signal,
103
+ });
104
+ if (!response.ok) {
105
+ throw new Error(`HTTP ${response.status}`);
106
+ }
107
+ const text = await response.text();
108
+ const data = JSON.parse(text);
109
+ if (!Array.isArray(data.plugins)) {
110
+ throw new Error("catalog missing plugins[]");
111
+ }
112
+ const host = (() => {
113
+ try {
114
+ return new URL(trimmed).hostname;
115
+ } catch {
116
+ return "";
117
+ }
118
+ })();
119
+ const origin = /gitee\.com/i.test(host) ? "gitee" : /githubusercontent\.com|github\.com/i.test(host) ? "github" : "remote";
120
+ return normalizeCatalog(data, origin);
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ }
125
+
126
+ function mapCatalogPlugin(row) {
127
+ return {
128
+ source: row.source || "npm",
129
+ spec: row.spec,
130
+ installSpec: row.spec,
131
+ title: row.title || row.spec,
132
+ description: row.description || "",
133
+ url: row.url || `https://www.npmjs.com/package/${row.spec}`,
134
+ stars: null,
135
+ bundle: null,
136
+ categories: Array.isArray(row.categories) ? row.categories : [],
137
+ tags: Array.isArray(row.tags) ? row.tags : [],
138
+ featured: row.featured === true,
139
+ rank: typeof row.rank === "number" ? row.rank : 0,
140
+ related: Array.isArray(row.related) ? row.related : [],
141
+ catalogId: row.id || row.spec,
142
+ reason: row.reason || null,
143
+ };
144
+ }
145
+
146
+ function filterCatalogPlugins(catalog, { category, query, personalize, installedNames }) {
147
+ const installed = new Set(
148
+ (Array.isArray(installedNames) ? installedNames : []).map((name) => String(name || "").trim()).filter(Boolean),
149
+ );
150
+ const allPlugins = Array.isArray(catalog?.plugins) ? catalog.plugins : [];
151
+ let rows = allPlugins.slice();
152
+
153
+ if (category && category !== "all") {
154
+ if (category === "featured") {
155
+ rows = rows.filter((row) => row.featured === true || (row.categories || []).includes("featured"));
156
+ } else {
157
+ rows = rows.filter((row) => (row.categories || []).includes(category));
158
+ }
159
+ }
160
+
161
+ const q = String(query || "")
162
+ .trim()
163
+ .toLowerCase();
164
+ if (q) {
165
+ rows = rows.filter((row) => {
166
+ const hay = [row.spec, row.title, row.description, ...(row.tags || []), ...(row.categories || [])]
167
+ .join(" ")
168
+ .toLowerCase();
169
+ return hay.includes(q);
170
+ });
171
+ }
172
+
173
+ if (personalize) {
174
+ const categoryScore = new Map();
175
+ const installedSpecs = new Set();
176
+ for (const row of allPlugins) {
177
+ if (!installed.has(row.spec) && !installed.has(row.title) && !installed.has(row.id)) continue;
178
+ installedSpecs.add(row.spec);
179
+ for (const cat of row.categories || []) {
180
+ if (cat === "featured") continue;
181
+ categoryScore.set(cat, (categoryScore.get(cat) || 0) + 2);
182
+ }
183
+ for (const rel of row.related || []) {
184
+ categoryScore.set(`rel:${rel}`, (categoryScore.get(`rel:${rel}`) || 0) + 5);
185
+ }
186
+ }
187
+
188
+ const scored = rows
189
+ .filter((row) => !installed.has(row.spec) && !installedSpecs.has(row.spec))
190
+ .map((row) => {
191
+ let score = (row.rank || 0) + (row.featured ? 12 : 0);
192
+ const reasons = [];
193
+ for (const cat of row.categories || []) {
194
+ if (cat === "featured") continue;
195
+ const boost = categoryScore.get(cat) || 0;
196
+ if (boost) {
197
+ score += boost * 3;
198
+ reasons.push(cat);
199
+ }
200
+ }
201
+ const relBoost = categoryScore.get(`rel:${row.spec}`) || 0;
202
+ if (relBoost) {
203
+ score += relBoost;
204
+ reasons.push("related");
205
+ }
206
+ if (!reasons.length && row.featured) reasons.push("featured");
207
+ return { ...row, reason: reasons[0] || (row.featured ? "featured" : "popular"), _score: score };
208
+ });
209
+
210
+ scored.sort((a, b) => b._score - a._score || String(a.spec).localeCompare(String(b.spec)));
211
+ return scored.map(({ _score, ...rest }) => rest);
212
+ }
213
+
214
+ rows.sort((a, b) => (b.rank || 0) - (a.rank || 0) || String(a.spec).localeCompare(String(b.spec)));
215
+ return rows;
216
+ }
8
217
 
9
218
  function githubHeaders(token) {
10
219
  const headers = {
@@ -166,13 +375,125 @@ class PluginDiscoveryService extends TypertRemoteService {
166
375
  maxInspect: z.number().min(0).max(30).default(10),
167
376
  githubToken: z.string().default(""),
168
377
  openPanelOnLoad: z.boolean().default(false),
378
+ /** 是否从远程 raw JSON 自动刷新推荐目录(优先 Gitee) */
379
+ catalogRemoteEnabled: z.boolean().default(true),
380
+ catalogGiteeUrl: z.string().default(DEFAULT_CATALOG_GITEE_URL),
381
+ catalogGithubUrl: z.string().default(DEFAULT_CATALOG_GITHUB_URL),
382
+ /** 远程检查间隔;期间复用内存/磁盘缓存 */
383
+ catalogRefreshTtlMs: z.number().min(0).max(86400000 * 7).default(3600000),
169
384
  });
170
385
 
171
386
  constructor(ctx, config) {
172
387
  super(ctx, "pluginDiscovery");
173
388
  this.config = config;
174
389
  this.cache = new Map();
390
+ this.catalog = pickNewerCatalog(loadBundledCatalog(), loadCachedCatalog());
391
+ this.catalogCheckedAt = 0;
392
+ this.catalogRefreshPromise = null;
175
393
  this.ctx.effect(() => this.registerTools());
394
+ // 启动后后台拉一次,不阻塞启动
395
+ this.ctx.effect(() => {
396
+ void this.refreshCatalog({ force: false }).catch(() => {});
397
+ return () => {};
398
+ });
399
+ }
400
+
401
+ getCatalog() {
402
+ return this.catalog || loadBundledCatalog();
403
+ }
404
+
405
+ catalogMeta() {
406
+ const catalog = this.getCatalog();
407
+ return {
408
+ version: catalog.version || 0,
409
+ updatedAt: catalog.updatedAt || "",
410
+ origin: catalog.origin || "bundled",
411
+ checkedAt: this.catalogCheckedAt || null,
412
+ };
413
+ }
414
+
415
+ remoteCatalogUrls() {
416
+ const urls = [];
417
+ const gitee = String(this.config.catalogGiteeUrl || "").trim();
418
+ const github = String(this.config.catalogGithubUrl || "").trim();
419
+ if (gitee) urls.push({ url: gitee, label: "gitee" });
420
+ if (github) urls.push({ url: github, label: "github" });
421
+ return urls;
422
+ }
423
+
424
+ async refreshCatalog(options = {}) {
425
+ const force = options.force === true;
426
+ if (!this.config.catalogRemoteEnabled) {
427
+ return { ok: true, skipped: true, reason: "remote disabled", catalog: this.catalogMeta() };
428
+ }
429
+ const ttl = this.config.catalogRefreshTtlMs ?? 3600000;
430
+ if (!force && this.catalogCheckedAt && Date.now() - this.catalogCheckedAt < ttl) {
431
+ return { ok: true, skipped: true, reason: "ttl", catalog: this.catalogMeta() };
432
+ }
433
+ if (this.catalogRefreshPromise) return this.catalogRefreshPromise;
434
+
435
+ this.catalogRefreshPromise = (async () => {
436
+ const errors = [];
437
+ let remote = null;
438
+ for (const entry of this.remoteCatalogUrls()) {
439
+ try {
440
+ remote = await fetchCatalogFromUrl(entry.url);
441
+ if (remote) {
442
+ remote.origin = entry.label || remote.origin;
443
+ break;
444
+ }
445
+ } catch (error) {
446
+ errors.push(`${entry.label}: ${error instanceof Error ? error.message : String(error)}`);
447
+ }
448
+ }
449
+ this.catalogCheckedAt = Date.now();
450
+ if (!remote) {
451
+ return {
452
+ ok: false,
453
+ message: errors.join("; ") || "remote catalog unavailable",
454
+ catalog: this.catalogMeta(),
455
+ };
456
+ }
457
+ const current = this.getCatalog();
458
+ if ((remote.version || 0) > (current.version || 0)) {
459
+ this.catalog = remote;
460
+ try {
461
+ saveCachedCatalog(remote);
462
+ } catch (error) {
463
+ this.ctx.logger?.warn?.("[plugin-discovery] save catalog cache failed", error);
464
+ }
465
+ this.ctx.logger?.info?.(
466
+ `[plugin-discovery] catalog updated ${current.version} -> ${remote.version} via ${remote.origin}`,
467
+ );
468
+ return {
469
+ ok: true,
470
+ updated: true,
471
+ from: current.version,
472
+ to: remote.version,
473
+ origin: remote.origin,
474
+ catalog: this.catalogMeta(),
475
+ };
476
+ }
477
+ // 同版本也刷新缓存时间戳,便于离线复用最近一次成功源
478
+ if ((remote.version || 0) === (current.version || 0) && remote.origin !== "bundled") {
479
+ this.catalog = { ...current, origin: remote.origin, updatedAt: remote.updatedAt || current.updatedAt };
480
+ try {
481
+ saveCachedCatalog(this.catalog);
482
+ } catch {
483
+ /* ignore */
484
+ }
485
+ }
486
+ return {
487
+ ok: true,
488
+ updated: false,
489
+ origin: remote.origin,
490
+ catalog: this.catalogMeta(),
491
+ };
492
+ })().finally(() => {
493
+ this.catalogRefreshPromise = null;
494
+ });
495
+
496
+ return this.catalogRefreshPromise;
176
497
  }
177
498
 
178
499
  getFromCache(key) {
@@ -236,14 +557,82 @@ class PluginDiscoveryService extends TypertRemoteService {
236
557
  return verified;
237
558
  }
238
559
 
560
+ async listCatalog(options) {
561
+ if (!this.config.enabled) {
562
+ return { ok: false, message: "发现插件已停用", categories: [], results: [], total: 0 };
563
+ }
564
+ const opts = options && typeof options === "object" ? options : {};
565
+ const mode = opts.mode === "category" || opts.mode === "all" ? opts.mode : "recommend";
566
+ const category = typeof opts.category === "string" ? opts.category.trim() : "";
567
+ const query = typeof opts.query === "string" ? opts.query : "";
568
+ let installedNames = Array.isArray(opts.installedNames) ? opts.installedNames : null;
569
+ if (!installedNames) {
570
+ const listed = await this.listInstalled();
571
+ installedNames = listed.ok ? listed.names : [];
572
+ }
573
+
574
+ // 打开推荐/分类时尝试按 TTL 拉远程;失败静默回退本地
575
+ await this.refreshCatalog({ force: opts.refresh === true }).catch(() => {});
576
+
577
+ const catalog = this.getCatalog();
578
+ const personalize = mode === "recommend";
579
+ const effectiveCategory = mode === "category" ? category || "featured" : mode === "all" ? "" : "";
580
+ const rows = filterCatalogPlugins(catalog, {
581
+ category: effectiveCategory,
582
+ query,
583
+ personalize,
584
+ installedNames,
585
+ });
586
+ const results = rows.map(mapCatalogPlugin);
587
+ return {
588
+ ok: true,
589
+ mode: personalize ? "recommend" : mode === "all" ? "all" : "category",
590
+ category: effectiveCategory || null,
591
+ updatedAt: catalog.updatedAt,
592
+ catalogVersion: catalog.version || 0,
593
+ catalogOrigin: catalog.origin || "bundled",
594
+ categories: catalog.categories,
595
+ results,
596
+ total: results.length,
597
+ };
598
+ }
599
+
239
600
  async search(query, options) {
240
601
  if (!this.config.enabled) {
241
602
  return { ok: false, message: "发现插件已停用", results: [], total: 0 };
242
603
  }
243
604
  const opts = options && typeof options === "object" ? options : {};
244
605
  const q = String(query || "").trim() || "dsh";
245
- const source = opts.source === "npm" || opts.source === "github" ? opts.source : "all";
606
+ const source =
607
+ opts.source === "npm" || opts.source === "github" || opts.source === "catalog"
608
+ ? opts.source
609
+ : "all";
246
610
  const verify = opts.verify === true;
611
+
612
+ if (source === "catalog") {
613
+ await this.refreshCatalog({ force: false }).catch(() => {});
614
+ const listed = await this.listInstalled();
615
+ const catalog = this.getCatalog();
616
+ const rows = filterCatalogPlugins(catalog, {
617
+ category: opts.category || "",
618
+ query: q === "dsh" ? "" : q,
619
+ personalize: opts.personalize === true,
620
+ installedNames: listed.ok ? listed.names : [],
621
+ });
622
+ const results = rows.map(mapCatalogPlugin);
623
+ return {
624
+ ok: true,
625
+ cached: false,
626
+ query: q,
627
+ source,
628
+ verify: false,
629
+ results,
630
+ total: results.length,
631
+ catalogVersion: catalog.version || 0,
632
+ catalogOrigin: catalog.origin || "bundled",
633
+ };
634
+ }
635
+
247
636
  const key = `${cacheKey(q, source)}:v${verify ? 1 : 0}`;
248
637
  const cached = this.getFromCache(key);
249
638
  if (cached && !opts.skipCache) {
@@ -478,7 +867,7 @@ class PluginDiscoveryService extends TypertRemoteService {
478
867
  query: { type: "string", required: true, description: "Search keywords" },
479
868
  source: {
480
869
  type: "string",
481
- enum: ["all", "npm", "github"],
870
+ enum: ["all", "npm", "github", "catalog"],
482
871
  description: "Limit search source",
483
872
  },
484
873
  },
@@ -491,6 +880,35 @@ class PluginDiscoveryService extends TypertRemoteService {
491
880
  }),
492
881
  ),
493
882
  );
883
+ disposers.push(
884
+ this.ctx.tools.register(
885
+ defineTool({
886
+ name: "plugin_discovery_catalog",
887
+ description:
888
+ "List curated DSH plugin recommendations and categories bundled with dsh-plugin-discovery.",
889
+ parameters: {
890
+ mode: {
891
+ type: "string",
892
+ enum: ["recommend", "category", "all"],
893
+ description: "recommend = personalized; category = filter by category; all = full catalog",
894
+ },
895
+ category: {
896
+ type: "string",
897
+ description: "Category id when mode=category (pet, theme, im, memory, ...)",
898
+ },
899
+ },
900
+ output: toolOutput,
901
+ async execute(args) {
902
+ return service.listCatalog({
903
+ mode: args.mode || "recommend",
904
+ category: args.category || "",
905
+ });
906
+ },
907
+ presentCall: (_args, partial) =>
908
+ present(`DSH plugin catalog: ${partial?.mode || "recommend"}`),
909
+ }),
910
+ ),
911
+ );
494
912
  disposers.push(
495
913
  this.ctx.tools.register(
496
914
  defineTool({
@@ -520,6 +938,7 @@ class PluginDiscoveryService extends TypertRemoteService {
520
938
  }
521
939
 
522
940
  markRemoteMethod(PluginDiscoveryService.prototype, "search");
941
+ markRemoteMethod(PluginDiscoveryService.prototype, "listCatalog");
523
942
  markRemoteMethod(PluginDiscoveryService.prototype, "resolve");
524
943
  markRemoteMethod(PluginDiscoveryService.prototype, "installPlugin");
525
944
  markRemoteMethod(PluginDiscoveryService.prototype, "listInstalled");
package/locale/en.json CHANGED
@@ -1,22 +1,25 @@
1
- {
2
- "meta": {
3
- "title": "Discover DSH plugins",
4
- "description": "Search GitHub and npm for community DSH bundles; install after inspect."
5
- },
6
- "panel": "Discover",
7
- "searchPlaceholder": "Keywords, e.g. pet, wallpaper, dsh",
8
- "search": "Search",
9
- "searching": "Searching…",
10
- "sourceAll": "All",
11
- "sourceNpm": "npm",
12
- "sourceGithub": "GitHub",
13
- "warning": "Community plugins are not official; review the repo before installing.",
14
- "noResults": "No DSH bundles matched.",
15
- "install": "Install",
16
- "installing": "Installing…",
17
- "inspectFailed": "Inspect failed",
18
- "bundle": "Bundle",
19
- "spec": "Install spec",
20
- "resolvePlaceholder": "Paste npm or GitHub URL",
21
- "resolve": "Resolve"
1
+ {
2
+ "meta": {
3
+ "title": "Discover DSH plugins",
4
+ "description": "Curated recommendations and categories for community DSH bundles; also search GitHub/npm to install."
5
+ },
6
+ "panel": "Discover",
7
+ "searchPlaceholder": "Keywords, e.g. pet, wallpaper, dsh",
8
+ "search": "Search",
9
+ "searching": "Searching…",
10
+ "sourceAll": "All",
11
+ "sourceNpm": "npm",
12
+ "sourceGithub": "GitHub",
13
+ "browseRecommend": "For you",
14
+ "browseCategory": "Categories",
15
+ "browseSearch": "Online search",
16
+ "warning": "Community plugins are not official; review the repo before installing.",
17
+ "noResults": "No DSH bundles matched.",
18
+ "install": "Install",
19
+ "installing": "Installing…",
20
+ "inspectFailed": "Inspect failed",
21
+ "bundle": "Bundle",
22
+ "spec": "Install spec",
23
+ "resolvePlaceholder": "Paste npm or GitHub URL",
24
+ "resolve": "Resolve"
22
25
  }
package/locale/zh.json CHANGED
@@ -1,22 +1,25 @@
1
- {
2
- "meta": {
3
- "title": "发现 DSH 插件",
4
- "description": "在 GitHub 与 npm 上搜索社区 DSH Bundle,经 inspect 校验后安装。"
5
- },
6
- "panel": "发现插件",
7
- "searchPlaceholder": "搜索关键词,例如 桌宠、壁纸、dsh",
8
- "search": "搜索",
9
- "searching": "搜索中…",
10
- "sourceAll": "全部",
11
- "sourceNpm": "npm",
12
- "sourceGithub": "GitHub",
13
- "warning": "社区插件非 DeepSeek 官方维护,安装前请自行审阅仓库与权限。",
14
- "noResults": "未找到符合条件的 DSH Bundle。",
15
- "install": "安装",
16
- "installing": "安装中…",
17
- "inspectFailed": "无法校验",
18
- "bundle": "Bundle",
19
- "spec": "安装 spec",
20
- "resolvePlaceholder": "粘贴 npm 或 GitHub 链接",
21
- "resolve": "解析并校验"
1
+ {
2
+ "meta": {
3
+ "title": "发现 DSH 插件",
4
+ "description": "精选推荐与分类浏览社区 DSH Bundle,也可在 GitHub / npm 搜索并安装。"
5
+ },
6
+ "panel": "发现插件",
7
+ "searchPlaceholder": "搜索关键词,例如 桌宠、壁纸、dsh",
8
+ "search": "搜索",
9
+ "searching": "搜索中…",
10
+ "sourceAll": "全部",
11
+ "sourceNpm": "npm",
12
+ "sourceGithub": "GitHub",
13
+ "browseRecommend": "为你推荐",
14
+ "browseCategory": "分类浏览",
15
+ "browseSearch": "在线搜索",
16
+ "warning": "社区插件非 DeepSeek 官方维护,安装前请自行审阅仓库与权限。",
17
+ "noResults": "未找到符合条件的 DSH Bundle。",
18
+ "install": "安装",
19
+ "installing": "安装中…",
20
+ "inspectFailed": "无法校验",
21
+ "bundle": "Bundle",
22
+ "spec": "安装 spec",
23
+ "resolvePlaceholder": "粘贴 npm 或 GitHub 链接",
24
+ "resolve": "解析并校验"
22
25
  }