@nanmicoder/dsh-skills-hub 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CONTRIBUTING.md +18 -0
  2. package/README.md +31 -82
  3. package/README_EN.md +31 -82
  4. package/README_ZH.md +2 -0
  5. package/compatibility.json +1 -1
  6. package/docs/COMPATIBILITY.md +34 -0
  7. package/docs/CONFIGURATION.md +38 -0
  8. package/docs/CONTRACT.md +25 -7
  9. package/docs/RELEASE.md +5 -5
  10. package/lib/client.js +1916 -1025
  11. package/lib/client.js.map +1 -1
  12. package/lib/config.js +2 -0
  13. package/lib/index.js +16 -6
  14. package/lib/market/cache.js +104 -30
  15. package/lib/market/disk-cache.js +113 -0
  16. package/lib/market/install-service.js +8 -6
  17. package/lib/market/market-service.js +79 -29
  18. package/lib/market/provider-fetch.js +9 -2
  19. package/lib/market/types.js +2 -1
  20. package/lib/skills/management.js +182 -4
  21. package/lib/skills/root.js +1 -1
  22. package/lib/types/client/api.d.ts +33 -5
  23. package/lib/types/client/components/InstallConfirmDialog.d.ts +6 -0
  24. package/lib/types/client/components/InstalledSkillDetail.d.ts +24 -0
  25. package/lib/types/client/components/InstalledSkills.d.ts +18 -1
  26. package/lib/types/client/components/MarkdownView.d.ts +7 -1
  27. package/lib/types/client/components/SkillAvatar.d.ts +1 -1
  28. package/lib/types/client/components/SkillDetailShell.d.ts +63 -0
  29. package/lib/types/client/components/SkillDetailView.d.ts +4 -6
  30. package/lib/types/client/components/SkillFiles.d.ts +32 -0
  31. package/lib/types/client/components/SourceStatusBar.d.ts +4 -0
  32. package/lib/types/client/icons.d.ts +4 -0
  33. package/lib/types/client/locales.d.ts +30 -0
  34. package/lib/types/client/relative-time.d.ts +13 -0
  35. package/lib/types/client/skill-markdown.d.ts +39 -0
  36. package/lib/types/client/state.d.ts +13 -4
  37. package/lib/types/config.d.ts +2 -0
  38. package/lib/types/market/cache.d.ts +62 -14
  39. package/lib/types/market/disk-cache.d.ts +16 -0
  40. package/lib/types/market/market-service.d.ts +15 -3
  41. package/lib/types/market/provider-fetch.d.ts +2 -0
  42. package/lib/types/skills/management.d.ts +63 -2
  43. package/lib/types/skills/root.d.ts +8 -0
  44. package/lib/types/web-routes.d.ts +2 -0
  45. package/lib/web-routes.js +42 -4
  46. package/package.json +1 -1
  47. package/release-notes/v0.0.1.md +1 -1
  48. package/release-notes/v0.0.2.md +12 -0
package/lib/config.js CHANGED
@@ -27,6 +27,7 @@ export const SKILLS_HUB_DEFAULTS = {
27
27
  requestRetries: 1,
28
28
  allowUninstall: true,
29
29
  pageSize: 24,
30
+ cacheTtlMinutes: 60,
30
31
  };
31
32
  /**
32
33
  * `null` must stay a legal value for `skillsRoot` (it is the "use the DSH
@@ -48,4 +49,5 @@ export const Config = z.object({
48
49
  requestRetries: z.natural().default(SKILLS_HUB_DEFAULTS.requestRetries),
49
50
  allowUninstall: z.boolean().default(SKILLS_HUB_DEFAULTS.allowUninstall),
50
51
  pageSize: z.natural().min(1).max(100).default(SKILLS_HUB_DEFAULTS.pageSize),
52
+ cacheTtlMinutes: z.natural().min(1).max(1440).default(SKILLS_HUB_DEFAULTS.cacheTtlMinutes),
51
53
  });
package/lib/index.js CHANGED
@@ -15,11 +15,13 @@
15
15
  *
16
16
  * @module dsh-skills-hub
17
17
  */
18
+ import * as path from 'node:path';
18
19
  import { Config, SKILLS_HUB_DEFAULTS } from "./config.js";
20
+ import { configureMarketCache } from "./market/cache.js";
19
21
  import { configureProviderFetch } from "./market/provider-fetch.js";
20
22
  import { setInstalledLookup, resetInstalledLookup } from "./market/market-service.js";
21
23
  import { installedLookupFrom, scanInstalledSkills } from "./skills/installed.js";
22
- import { resolveSkillsRoot, resolveSkillsScanRoots } from "./skills/root.js";
24
+ import { resolveDshHome, resolveSkillsRoot, resolveSkillsScanRoots } from "./skills/root.js";
23
25
  import { registerSkillsHubRoutes, setSkillsHubPageSize, } from "./web-routes.js";
24
26
  export const name = 'skills-hub';
25
27
  /**
@@ -52,6 +54,7 @@ function resolveConfig(config) {
52
54
  requestRetries: config?.requestRetries ?? SKILLS_HUB_DEFAULTS.requestRetries,
53
55
  allowUninstall: config?.allowUninstall ?? SKILLS_HUB_DEFAULTS.allowUninstall,
54
56
  pageSize: config?.pageSize ?? SKILLS_HUB_DEFAULTS.pageSize,
57
+ cacheTtlMinutes: config?.cacheTtlMinutes ?? SKILLS_HUB_DEFAULTS.cacheTtlMinutes,
55
58
  };
56
59
  }
57
60
  /**
@@ -101,6 +104,11 @@ export function apply(ctx, config) {
101
104
  retries: resolved.requestRetries,
102
105
  });
103
106
  setSkillsHubPageSize(resolved.pageSize);
107
+ configureMarketCache({
108
+ directory: path.join(resolveDshHome(), 'cache', 'skills-hub', 'v1'),
109
+ ttlMs: resolved.cacheTtlMinutes * 60_000,
110
+ onError: (error) => ctx.logger.warn(`skills-hub: persistent cache unavailable: ${String(error)}`),
111
+ });
104
112
  let disposed = false;
105
113
  /** Rebuild the installed index and hand it to the market layer. */
106
114
  const refreshInstalledIndex = async () => {
@@ -135,11 +143,12 @@ export function apply(ctx, config) {
135
143
  // republishing it).
136
144
  resetInstalledLookup();
137
145
  }, 'skills-hub: activation state');
138
- // First scan: `apply()` is synchronous, so this cannot be awaited here. Until
139
- // it settles the market layer keeps its empty default lookup (skills show as
140
- // installable, and the installer's own conflict check still refuses to clobber
141
- // an existing directory), and /installed reads the disk directly.
142
- void rescan();
146
+ // Apply is synchronous, but the first HTTP read must wait for install
147
+ // annotations even when a persisted snapshot arrives before the scan does.
148
+ const initialScan = refreshInstalledIndex().catch((error) => {
149
+ ctx.logger.warn(`skills-hub: installed-skill index refresh failed: ${String(error)}`);
150
+ });
151
+ void initialScan.then(() => refreshSkillCatalog(ctx));
143
152
  // The Web server may bind before or after this plugin (the Loader activates
144
153
  // rows concurrently), and older compositions name the service `httpServer`.
145
154
  // Bind lazily on the first service event that makes a server reachable.
@@ -159,6 +168,7 @@ export function apply(ctx, config) {
159
168
  skillsRoot: () => resolveSkillsRoot(resolved.skillsRoot),
160
169
  allowUninstall: () => resolved.allowUninstall,
161
170
  rescan,
171
+ ready: () => initialScan,
162
172
  };
163
173
  // `ctx.effect` runs the factory immediately; `dsh-host-webserver` throws on a
164
174
  // duplicate route path, so the flag may only be set once registration has
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Skills Market — in-memory TTL cache with stale-while-error support, plus
2
+ * Skills Market — memory + persistent TTL cache with stale-while-error support, plus
3
3
  * per-source health tracking.
4
4
  *
5
5
  * Only *upstream* data is cached here (list pages, details, file bodies).
@@ -7,56 +7,128 @@
7
7
  * and is deliberately never cached: a skill installed a second ago must show up
8
8
  * as installed on the very next list call.
9
9
  *
10
- * Everything lives in module scope, so the module needs no DSH service and no
11
- * filesystem — a unit test can drive it with plain `node --test`.
10
+ * The host configures disk storage at activation. Without a directory the
11
+ * cache stays memory-only, so provider tests never touch a user's Harness home.
12
12
  */
13
- /** Hard cap so a long-lived host process cannot grow the map without bound. */
14
- const MAX_ENTRIES = 500;
15
- /** Per-payload TTLs: cheap-to-refetch list pages expire sooner than file bodies. */
13
+ import { DiskMarketCache, MAX_CACHE_ENTRIES, MAX_STALE_AGE_MS } from "./disk-cache.js";
14
+ /** Default snapshot lifetime; activation can override it with cacheTtlMinutes. */
16
15
  export const MARKET_TTL = {
17
- list: 5 * 60_000,
18
- search: 2 * 60_000,
19
- detail: 10 * 60_000,
20
- files: 10 * 60_000,
21
- fileContent: 30 * 60_000,
16
+ list: 60 * 60_000,
17
+ search: 60 * 60_000,
18
+ detail: 60 * 60_000,
19
+ files: 60 * 60_000,
20
+ fileContent: 60 * 60_000,
22
21
  };
23
- class MarketCache {
22
+ const stats = {
23
+ hits: 0,
24
+ misses: 0,
25
+ staleServed: 0,
26
+ upstreamRequests: 0,
27
+ forcedRefreshes: 0,
28
+ };
29
+ /** Record one cache event. Kept tiny so it can sit on the request path. */
30
+ export function noteMarketStat(entry) {
31
+ stats[entry] += 1;
32
+ }
33
+ /** Snapshot of the counters; the caller cannot mutate the live object. */
34
+ export function getMarketStats() {
35
+ return { ...stats };
36
+ }
37
+ export class MarketCache {
38
+ options;
24
39
  entries = new Map();
25
- get(key) {
26
- const entry = this.entries.get(key);
40
+ disk;
41
+ warned = false;
42
+ ttlMs;
43
+ constructor(options = {}) {
44
+ this.options = options;
45
+ this.ttlMs = options.ttlMs ?? MARKET_TTL.list;
46
+ if (options.directory)
47
+ this.disk = new DiskMarketCache(options.directory);
48
+ }
49
+ warn(error) {
50
+ if (this.warned)
51
+ return;
52
+ this.warned = true;
53
+ this.options.onError?.(error);
54
+ }
55
+ remember(key, entry) {
56
+ this.entries.delete(key);
57
+ this.entries.set(key, entry);
58
+ if (this.entries.size > MAX_CACHE_ENTRIES) {
59
+ const oldest = this.entries.keys().next().value;
60
+ if (oldest !== undefined)
61
+ this.entries.delete(oldest);
62
+ }
63
+ }
64
+ async read(key) {
65
+ const existing = this.entries.get(key);
66
+ if (existing)
67
+ return existing;
68
+ try {
69
+ const restored = await this.disk?.read(key);
70
+ // A concurrent upstream response may have replaced the snapshot while
71
+ // the disk read was pending. Never overwrite that newer memory entry.
72
+ const current = this.entries.get(key);
73
+ if (current)
74
+ return current;
75
+ if (restored)
76
+ this.remember(key, restored);
77
+ return restored;
78
+ }
79
+ catch (error) {
80
+ // A corrupt or unwritable cache must never break the market request.
81
+ this.warn(error);
82
+ return undefined;
83
+ }
84
+ }
85
+ /**
86
+ * Read a fresh entry.
87
+ *
88
+ * The record carries `storedAt` on purpose: a hit is a *snapshot*, and the
89
+ * caller must be able to say how old it is. Returning only the payload is how
90
+ * the panel ended up reporting "fetched just now" for data it had not touched
91
+ * in ten minutes.
92
+ */
93
+ async getRecord(key) {
94
+ const entry = await this.read(key);
27
95
  if (!entry)
28
96
  return undefined;
29
- if (Date.now() > entry.expiresAt)
97
+ if (Date.now() >= Math.min(entry.expiresAt, entry.storedAt + this.ttlMs))
30
98
  return undefined;
31
99
  // LRU touch: re-insert so the oldest key is the least recently used one.
32
100
  this.entries.delete(key);
33
101
  this.entries.set(key, entry);
34
- return entry.value;
102
+ return { value: entry.value, storedAt: entry.storedAt };
35
103
  }
36
104
  /** Returns the entry even when expired — used for the stale-while-error fallback. */
37
- getStale(key) {
38
- const entry = this.entries.get(key);
39
- if (!entry)
105
+ async getStale(key) {
106
+ const entry = await this.read(key);
107
+ if (!entry || Date.now() - entry.storedAt > MAX_STALE_AGE_MS)
40
108
  return undefined;
41
109
  return { value: entry.value, storedAt: entry.storedAt };
42
110
  }
43
- set(key, value, ttlMs) {
44
- if (this.entries.has(key))
45
- this.entries.delete(key);
111
+ async set(key, value, ttlMs = this.ttlMs) {
46
112
  const now = Date.now();
47
- this.entries.set(key, { value, expiresAt: now + ttlMs, storedAt: now });
48
- if (this.entries.size > MAX_ENTRIES) {
49
- // Map iteration is insertion-ordered, so the first key is the oldest.
50
- const oldest = this.entries.keys().next().value;
51
- if (oldest !== undefined)
52
- this.entries.delete(oldest);
113
+ const entry = { value, expiresAt: now + ttlMs, storedAt: now };
114
+ this.remember(key, entry);
115
+ try {
116
+ await this.disk?.write(key, entry);
53
117
  }
118
+ catch (error) {
119
+ this.warn(error);
120
+ }
121
+ return now;
54
122
  }
55
123
  clear() {
56
124
  this.entries.clear();
57
125
  }
58
126
  }
59
- export const marketCache = new MarketCache();
127
+ export let marketCache = new MarketCache();
128
+ /** Replace memory state when activating a profile; disk entries are read lazily. */
129
+ export function configureMarketCache(options) {
130
+ marketCache = new MarketCache(options);
131
+ }
60
132
  function freshHealth() {
61
133
  return {
62
134
  clawhub: { status: 'ok' },
@@ -106,8 +178,10 @@ export function resetSourceHealth() {
106
178
  sourceHealth[source] = fresh[source];
107
179
  }
108
180
  }
109
- /** Test hook: drop cached payloads and reset source health. */
181
+ /** Test hook: drop memory payloads, reset source health and the counters. */
110
182
  export function resetMarketCache() {
111
183
  marketCache.clear();
112
184
  resetSourceHealth();
185
+ for (const entry of Object.keys(stats))
186
+ stats[entry] = 0;
113
187
  }
@@ -0,0 +1,113 @@
1
+ /** Atomic, bounded storage for upstream snapshots; never stores install state. */
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import * as fs from 'node:fs/promises';
4
+ import * as path from 'node:path';
5
+ export const MAX_CACHE_ENTRIES = 500;
6
+ export const MAX_STALE_AGE_MS = 7 * 24 * 60 * 60_000;
7
+ const MAX_ENTRY_BYTES = 5 * 1024 * 1024;
8
+ const MAX_DISK_BYTES = 64 * 1024 * 1024;
9
+ const CACHE_FILE = /^[a-f0-9]{64}\.json$/;
10
+ const TEMP_FILE = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\.tmp$/;
11
+ const MAX_TEMP_AGE_MS = 60 * 60_000;
12
+ function snapshotHash(key, entry) {
13
+ return createHash('sha256').update(JSON.stringify([2, key, entry.storedAt, entry.expiresAt, entry.value])).digest('hex');
14
+ }
15
+ export class DiskMarketCache {
16
+ directory;
17
+ writes = Promise.resolve();
18
+ constructor(directory) {
19
+ this.directory = directory;
20
+ }
21
+ filename(key) {
22
+ return path.join(this.directory, `${createHash('sha256').update(key).digest('hex')}.json`);
23
+ }
24
+ async read(key) {
25
+ let handle;
26
+ try {
27
+ handle = await fs.open(this.filename(key), 'r');
28
+ if ((await handle.stat()).size > MAX_ENTRY_BYTES)
29
+ return undefined;
30
+ const record = JSON.parse(await handle.readFile('utf8'));
31
+ if (!record || record.version !== 2 || record.key !== key || !('value' in record))
32
+ return undefined;
33
+ const { storedAt, expiresAt, value } = record;
34
+ if (typeof storedAt !== 'number' || !Number.isFinite(storedAt) || storedAt < 0
35
+ || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || expiresAt < storedAt
36
+ || storedAt > Date.now() || Date.now() - storedAt > MAX_STALE_AGE_MS)
37
+ return undefined;
38
+ const entry = { storedAt, expiresAt, value };
39
+ if (record.sha256 !== snapshotHash(key, entry))
40
+ return undefined;
41
+ return entry;
42
+ }
43
+ catch (error) {
44
+ if (error.code === 'ENOENT')
45
+ return undefined;
46
+ throw error;
47
+ }
48
+ finally {
49
+ await handle?.close();
50
+ }
51
+ }
52
+ async write(key, entry) {
53
+ const body = JSON.stringify({ version: 2, key, ...entry, sha256: snapshotHash(key, entry) });
54
+ const oversized = Buffer.byteLength(body) > MAX_ENTRY_BYTES;
55
+ // Serialize publication and pruning within this host. Each response awaits
56
+ // its write, so quitting after reading a page does not lose a delayed flush.
57
+ const pending = this.writes.then(async () => {
58
+ // An unpersistable refresh must not resurrect the previous version after
59
+ // restart. Serialize invalidation with ordinary writes of the same key.
60
+ if (oversized) {
61
+ await fs.rm(this.filename(key), { force: true });
62
+ return;
63
+ }
64
+ await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
65
+ const temporary = path.join(this.directory, `${randomUUID()}.tmp`);
66
+ try {
67
+ await fs.writeFile(temporary, body, { mode: 0o600, flag: 'wx' });
68
+ await fs.rename(temporary, this.filename(key));
69
+ await this.prune();
70
+ }
71
+ finally {
72
+ await fs.rm(temporary, { force: true });
73
+ }
74
+ });
75
+ this.writes = pending.catch(() => { });
76
+ await pending;
77
+ }
78
+ async prune() {
79
+ const names = (await fs.readdir(this.directory)).filter((name) => CACHE_FILE.test(name) || TEMP_FILE.test(name));
80
+ const records = await Promise.all(names.map(async (name) => {
81
+ const filename = path.join(this.directory, name);
82
+ try {
83
+ const stat = await fs.stat(filename);
84
+ if (TEMP_FILE.test(name)) {
85
+ // A killed host can leave a partial write. Leave recent files alone:
86
+ // another host may still be publishing in this shared home.
87
+ if (Date.now() - stat.mtimeMs > MAX_TEMP_AGE_MS)
88
+ await fs.rm(filename, { force: true });
89
+ return undefined;
90
+ }
91
+ return { filename, size: stat.size, modifiedAt: stat.mtimeMs };
92
+ }
93
+ catch (error) {
94
+ if (error.code === 'ENOENT')
95
+ return undefined;
96
+ throw error;
97
+ }
98
+ }));
99
+ const newest = records.filter((record) => record !== undefined).sort((a, b) => b.modifiedAt - a.modifiedAt);
100
+ let count = 0;
101
+ let bytes = 0;
102
+ for (const record of newest) {
103
+ if (Date.now() - record.modifiedAt > MAX_STALE_AGE_MS
104
+ || record.size > MAX_ENTRY_BYTES || count >= MAX_CACHE_ENTRIES || bytes + record.size > MAX_DISK_BYTES) {
105
+ await fs.rm(record.filename, { force: true });
106
+ }
107
+ else {
108
+ count += 1;
109
+ bytes += record.size;
110
+ }
111
+ }
112
+ }
113
+ }
@@ -22,7 +22,8 @@ import * as fs from 'node:fs/promises';
22
22
  import * as path from 'node:path';
23
23
  import { clawhubProvider } from "./clawhub-provider.js";
24
24
  import { skillhubProvider } from "./skillhub-provider.js";
25
- import { getMarketSkillDetail } from "./market-service.js";
25
+ import { getMarketSkillDetail, resolveMarketSkill } from "./market-service.js";
26
+ import { withProviderConfiguration } from "./provider-fetch.js";
26
27
  import { INSTALL_META_FILE, parseInstalledMeta, readSkillHeadline, } from "../skills/installed.js";
27
28
  import { MARKET_ERROR_CODES, MARKET_LIMITS, sanitizeDirName, skillId, } from "./types.js";
28
29
  const providers = {
@@ -104,8 +105,7 @@ function hasErrorCode(error, code) {
104
105
  }
105
106
  /** The upstream detail for a skill, or the market's own "not installable" verdict. */
106
107
  async function loadDetail(source, slug) {
107
- const { skill } = await getMarketSkillDetail(source, slug);
108
- return skill;
108
+ return resolveMarketSkill(source, slug);
109
109
  }
110
110
  /**
111
111
  * Refuse to publish over anything we do not own.
@@ -155,7 +155,10 @@ async function readJsonIfExists(filePath) {
155
155
  * @throws {import('./types.ts').MarketUpstreamError} for upstream failures,
156
156
  * unwrapped so the route can classify them (404 vs 502).
157
157
  */
158
- export async function installMarketSkill(source, slug, options) {
158
+ export function installMarketSkill(source, slug, options) {
159
+ return withProviderConfiguration(() => installMarketSkillWithConfiguration(source, slug, options));
160
+ }
161
+ async function installMarketSkillWithConfiguration(source, slug, options) {
159
162
  const dirName = sanitizeDirName(slug);
160
163
  if (dirName === null) {
161
164
  throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill slug cannot be used as a directory name: ${slug}`);
@@ -188,8 +191,7 @@ async function performInstall(source, slug, dirName, id, skillsRootInput) {
188
191
  if (detail.installState === 'not-installable') {
189
192
  throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill is not installable (${detail.notInstallableReason ?? 'unknown'}): ${slug}`);
190
193
  }
191
- // The cached detail may be older than the provider's version index, so the
192
- // file list is fetched fresh at install time (mirrors the reference).
194
+ // Re-read the file inventory for the freshly resolved manifest version.
193
195
  const files = await providers[source].listFiles(slug, detail.version);
194
196
  if (files.length === 0 || !files.some((file) => file.path === 'SKILL.md')) {
195
197
  throw new MarketInstallError(400, MARKET_ERROR_CODES.notInstallable, `Skill has no installable SKILL.md: ${slug}`);
@@ -5,12 +5,13 @@
5
5
  * cross-source dedupe, per-source health/degradation reporting, TTL caching
6
6
  * (stale-while-error) and locally-computed install state.
7
7
  *
8
- * The module is deliberately free of DSH services and of the filesystem: the
8
+ * The module is deliberately free of DSH services: the
9
9
  * "what is installed locally" question is answered by an injected
10
10
  * `InstalledLookup` (see `setInstalledLookup`), so this file can be unit-tested
11
11
  * by pointing the providers at a stub server.
12
12
  */
13
- import { getSourceHealth, markSourceHealth, MARKET_TTL, marketCache } from "./cache.js";
13
+ import { getSourceHealth, markSourceHealth, marketCache, noteMarketStat } from "./cache.js";
14
+ import { getProviderBase, withProviderConfiguration } from "./provider-fetch.js";
14
15
  import { clawhubProvider } from "./clawhub-provider.js";
15
16
  import { skillhubProvider } from "./skillhub-provider.js";
16
17
  import { MARKET_LIMITS, MARKET_SOURCES, detectMarketLanguage, sanitizeDirName, skillId, } from "./types.js";
@@ -154,23 +155,35 @@ export function dedupeSkills(items) {
154
155
  * segment so every key is obviously source-scoped.
155
156
  */
156
157
  function providerPageCacheKey(source, kind, params) {
157
- return `${kind}:${source}:${JSON.stringify([params.q ?? null, params.cursor ?? null, params.limit])}`;
158
+ return `${kind}:${source}:${JSON.stringify([getProviderBase(source), params.q ?? null, params.cursor ?? null, params.limit])}`;
158
159
  }
159
- async function fetchProviderPage(source, params) {
160
+ async function fetchProviderPage(source, params, cache, options = {}) {
160
161
  const isSearch = Boolean(params.q);
161
162
  const cacheKey = providerPageCacheKey(source, isSearch ? 'search' : 'list', params);
162
- const ttl = isSearch ? MARKET_TTL.search : MARKET_TTL.list;
163
- const cached = marketCache.get(cacheKey);
164
- if (cached) {
165
- return { page: cached, status: { status: 'ok', fetchedAt: Date.now(), fromCache: true } };
163
+ const forced = options.force === true;
164
+ if (forced) {
165
+ // A reader asked for this page on purpose; the entry is still rewritten
166
+ // below, so the refresh costs one request and then serves everyone again.
167
+ noteMarketStat('forcedRefreshes');
168
+ }
169
+ else {
170
+ const hit = await cache.getRecord(cacheKey);
171
+ if (hit) {
172
+ noteMarketStat('hits');
173
+ // The real fetch time travels with the payload: a hit is a snapshot, and
174
+ // the panel has to be able to say how old it is.
175
+ return { page: hit.value, status: { status: 'ok', fetchedAt: hit.storedAt, fromCache: true } };
176
+ }
166
177
  }
178
+ noteMarketStat('misses');
167
179
  try {
168
180
  const q = params.q;
169
181
  const page = isSearch && q !== undefined
170
182
  ? await providers[source].search({ q, cursor: params.cursor, limit: params.limit })
171
183
  : await providers[source].list({ cursor: params.cursor, limit: params.limit });
172
- marketCache.set(cacheKey, page, ttl);
173
- return { page, status: { status: 'ok', fetchedAt: Date.now(), fromCache: false } };
184
+ noteMarketStat('upstreamRequests');
185
+ const storedAt = await cache.set(cacheKey, page);
186
+ return { page, status: { status: 'ok', fetchedAt: storedAt, fromCache: false } };
174
187
  }
175
188
  catch (error) {
176
189
  const message = error instanceof Error ? error.message : String(error);
@@ -181,8 +194,9 @@ async function fetchProviderPage(source, params) {
181
194
  markSourceHealth(source, 'failed', message);
182
195
  // Stale-while-error: a degraded source still serves its last good page, so a
183
196
  // temporary upstream outage does not empty the panel.
184
- const stale = marketCache.getStale(cacheKey);
197
+ const stale = await cache.getStale(cacheKey);
185
198
  if (stale) {
199
+ noteMarketStat('staleServed');
186
200
  return {
187
201
  page: stale.value,
188
202
  status: { status: 'cached', fetchedAt: stale.storedAt, fromCache: true, error: message },
@@ -191,7 +205,11 @@ async function fetchProviderPage(source, params) {
191
205
  return { page: null, status: { ...getSourceHealth(source), fromCache: false, error: message } };
192
206
  }
193
207
  }
194
- export async function listMarketSkills(params) {
208
+ export function listMarketSkills(params) {
209
+ const cache = marketCache;
210
+ return withProviderConfiguration(() => listMarketSkillsWithCache(params, cache));
211
+ }
212
+ async function listMarketSkillsWithCache(params, cache) {
195
213
  const cursor = decodeCursor(params.cursor);
196
214
  const isFirstPage = !params.cursor;
197
215
  const activeSources = params.source === 'all' ? MARKET_SOURCES : [params.source];
@@ -207,7 +225,7 @@ export async function listMarketSkills(params) {
207
225
  // ClawHub search has no pagination: ask it for one big page and let the
208
226
  // aggregate cap apply, instead of pretending a second page exists.
209
227
  const limit = q && source === 'clawhub' ? MARKET_LIMITS.searchResultCap : params.limit;
210
- outcomes.set(source, await fetchProviderPage(source, { q, cursor: providerCursor, limit }));
228
+ outcomes.set(source, await fetchProviderPage(source, { q, cursor: providerCursor, limit }, cache, { force: params.refresh === true }));
211
229
  }));
212
230
  let merged = [];
213
231
  const nextCursor = {};
@@ -239,20 +257,38 @@ export async function listMarketSkills(params) {
239
257
  return { items: merged, nextCursor: encodeCursor(nextCursor), sources };
240
258
  }
241
259
  // ─── Detail / file content ───────────────────────────────────────────────────
242
- export async function getMarketSkillDetail(source, slug) {
243
- const cacheKey = `detail:${source}:${slug}`;
244
- let detail = marketCache.get(cacheKey);
245
- let sourceStatus = { status: 'ok', fetchedAt: Date.now(), fromCache: true };
246
- if (!detail) {
260
+ export function getMarketSkillDetail(source, slug, options = {}) {
261
+ const cache = marketCache;
262
+ return withProviderConfiguration(() => getMarketSkillDetailWithCache(source, slug, options, cache));
263
+ }
264
+ async function getMarketSkillDetailWithCache(source, slug, options, cache) {
265
+ const cacheKey = `detail:${source}:${JSON.stringify([getProviderBase(source), slug])}`;
266
+ const forced = options.force === true;
267
+ if (forced)
268
+ noteMarketStat('forcedRefreshes');
269
+ const hit = forced ? undefined : await cache.getRecord(cacheKey);
270
+ let detail;
271
+ let sourceStatus;
272
+ if (hit) {
273
+ noteMarketStat('hits');
274
+ detail = hit.value;
275
+ sourceStatus = { status: 'ok', fetchedAt: hit.storedAt, fromCache: true };
276
+ }
277
+ else {
278
+ noteMarketStat('misses');
247
279
  try {
248
280
  detail = await providers[source].detail(slug);
249
- marketCache.set(cacheKey, detail, MARKET_TTL.detail);
250
- sourceStatus = { status: 'ok', fetchedAt: Date.now(), fromCache: false };
281
+ noteMarketStat('upstreamRequests');
282
+ const storedAt = await cache.set(cacheKey, detail);
283
+ sourceStatus = { status: 'ok', fetchedAt: storedAt, fromCache: false };
251
284
  }
252
285
  catch (error) {
253
- const stale = marketCache.getStale(cacheKey);
286
+ if (options.allowStale === false)
287
+ throw error;
288
+ const stale = await cache.getStale(cacheKey);
254
289
  if (!stale)
255
290
  throw error;
291
+ noteMarketStat('staleServed');
256
292
  detail = stale.value;
257
293
  sourceStatus = {
258
294
  status: 'cached',
@@ -274,13 +310,20 @@ export function isValidMarketFilePath(filePath) {
274
310
  return false;
275
311
  return true;
276
312
  }
277
- export async function getMarketFileContent(source, slug, filePath) {
313
+ export function getMarketFileContent(source, slug, filePath) {
314
+ const cache = marketCache;
315
+ return withProviderConfiguration(() => getMarketFileContentWithCache(source, slug, filePath, cache));
316
+ }
317
+ async function getMarketFileContentWithCache(source, slug, filePath, cache) {
278
318
  // JSON-encoded for the same reason as the list keys: slug and filePath are
279
319
  // both caller-controlled, so a `:`-joined key is ambiguous.
280
- const cacheKey = `file:${source}:${JSON.stringify([slug, filePath])}`;
281
- const cached = marketCache.get(cacheKey);
282
- if (cached)
283
- return cached;
320
+ const cacheKey = `file:${source}:${JSON.stringify([getProviderBase(source), slug, filePath])}`;
321
+ const cached = await cache.getRecord(cacheKey);
322
+ if (cached) {
323
+ noteMarketStat('hits');
324
+ return cached.value;
325
+ }
326
+ noteMarketStat('misses');
284
327
  const fetched = await providers[source].fetchFile(slug, filePath);
285
328
  let content = fetched.content;
286
329
  let truncated = false;
@@ -295,7 +338,7 @@ export async function getMarketFileContent(source, slug, filePath) {
295
338
  size: fetched.size,
296
339
  truncated,
297
340
  };
298
- marketCache.set(cacheKey, result, MARKET_TTL.fileContent);
341
+ await cache.set(cacheKey, result);
299
342
  return result;
300
343
  }
301
344
  // ─── Status ──────────────────────────────────────────────────────────────────
@@ -305,8 +348,15 @@ export function getMarketStatus() {
305
348
  skillhub: getSourceHealth('skillhub'),
306
349
  };
307
350
  }
308
- /** Look up a single skill (used by install) — the detail path, bypassing list. */
351
+ /**
352
+ * Look up a single skill for an install (the detail path, bypassing the list).
353
+ *
354
+ * Always forced: a cached manifest is a snapshot of the *catalogue*, and
355
+ * installing is the one action that has to be authorized by what upstream says
356
+ * right now — the file bytes are fetched fresh and hash-verified against the
357
+ * list, so the list must come from the same generation as those bytes.
358
+ */
309
359
  export async function resolveMarketSkill(source, slug) {
310
- const { skill } = await getMarketSkillDetail(source, slug);
360
+ const { skill } = await getMarketSkillDetail(source, slug, { force: true, allowStale: false });
311
361
  return skill;
312
362
  }
@@ -10,6 +10,7 @@
10
10
  * `process.env` at module scope, so the module (and every provider built on it)
11
11
  * is directly unit-testable by pointing the bases at a local stub.
12
12
  */
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
13
14
  import { markSourceHealth } from "./cache.js";
14
15
  import { MARKET_ERROR_CODES, MARKET_LIMITS, MarketUpstreamError } from "./types.js";
15
16
  const DEFAULT_CONFIG = {
@@ -20,6 +21,11 @@ const DEFAULT_CONFIG = {
20
21
  userAgent: 'dsh-skills-hub',
21
22
  };
22
23
  let currentConfig = { ...DEFAULT_CONFIG };
24
+ const requestConfig = new AsyncLocalStorage();
25
+ /** Keep cache keys and all chained upstream reads on one configuration snapshot. */
26
+ export function withProviderConfiguration(operation) {
27
+ return requestConfig.run(requestConfig.getStore() ?? currentConfig, operation);
28
+ }
23
29
  /** Strip trailing slashes so `new URL('/api/...', base)` never doubles a separator. */
24
30
  function normalizeBaseUrl(value) {
25
31
  return value.replace(/\/+$/, '');
@@ -51,7 +57,8 @@ export function configureProviderFetch(config) {
51
57
  }
52
58
  /** Base URL of one upstream, as resolved from the active configuration. */
53
59
  export function getProviderBase(source) {
54
- return source === 'clawhub' ? currentConfig.clawhubBaseUrl : currentConfig.skillhubBaseUrl;
60
+ const config = requestConfig.getStore() ?? currentConfig;
61
+ return source === 'clawhub' ? config.clawhubBaseUrl : config.skillhubBaseUrl;
55
62
  }
56
63
  /**
57
64
  * An upstream answered with a non-2xx status (or a retryable 429/5xx).
@@ -93,7 +100,7 @@ function requestHeaders(init, userAgent) {
93
100
  * provider can map it to a meaningful error.
94
101
  */
95
102
  export async function providerFetch(source, url, init) {
96
- const config = currentConfig;
103
+ const config = requestConfig.getStore() ?? currentConfig;
97
104
  const attempts = Math.max(1, config.retries + 1);
98
105
  let lastError;
99
106
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -70,7 +70,8 @@ export function sanitizeDirName(slug) {
70
70
  return name;
71
71
  }
72
72
  const LANG_MAP = {
73
- md: 'markdown', ts: 'typescript', tsx: 'typescript',
73
+ md: 'markdown', markdown: 'markdown', mdx: 'markdown',
74
+ ts: 'typescript', tsx: 'typescript',
74
75
  js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript',
75
76
  json: 'json', yaml: 'yaml', yml: 'yaml', sh: 'bash', bash: 'bash', zsh: 'bash',
76
77
  py: 'python', toml: 'toml', css: 'css', html: 'html',