akm-cli 0.9.0-beta.45 → 0.9.0-beta.47

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 (81) hide show
  1. package/dist/assets/wiki/ingest-workflow-template.md +17 -10
  2. package/dist/cli/shared.js +28 -0
  3. package/dist/cli.js +1 -2
  4. package/dist/commands/env/env-cli.js +16 -24
  5. package/dist/commands/env/secret-cli.js +12 -20
  6. package/dist/commands/graph/graph-cli.js +5 -13
  7. package/dist/commands/graph/graph.js +3 -3
  8. package/dist/commands/improve/consolidate/chunking.js +141 -0
  9. package/dist/commands/improve/consolidate/eligibility.js +64 -0
  10. package/dist/commands/improve/consolidate/merge.js +145 -0
  11. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  12. package/dist/commands/improve/consolidate/types.js +4 -0
  13. package/dist/commands/improve/consolidate.js +20 -571
  14. package/dist/commands/improve/distill.js +5 -9
  15. package/dist/commands/improve/eligibility.js +434 -0
  16. package/dist/commands/improve/extract-cli.js +9 -1
  17. package/dist/commands/improve/extract.js +5 -19
  18. package/dist/commands/improve/improve-auto-accept.js +4 -8
  19. package/dist/commands/improve/improve-cli.js +35 -60
  20. package/dist/commands/improve/improve-result-file.js +5 -23
  21. package/dist/commands/improve/improve-session.js +58 -0
  22. package/dist/commands/improve/improve.js +107 -3606
  23. package/dist/commands/improve/locks.js +154 -0
  24. package/dist/commands/improve/loop-stages.js +1079 -0
  25. package/dist/commands/improve/preparation.js +1963 -0
  26. package/dist/commands/improve/recombine.js +6 -12
  27. package/dist/commands/improve/reflect.js +29 -34
  28. package/dist/commands/proposal/drain.js +25 -48
  29. package/dist/commands/proposal/proposal-cli.js +21 -31
  30. package/dist/commands/proposal/validators/proposals.js +3 -7
  31. package/dist/commands/read/curate.js +70 -14
  32. package/dist/commands/read/knowledge.js +2 -2
  33. package/dist/commands/sources/self-update.js +2 -2
  34. package/dist/commands/sources/stash-cli.js +9 -37
  35. package/dist/commands/tasks/tasks-cli.js +19 -27
  36. package/dist/commands/wiki-cli.js +21 -35
  37. package/dist/core/config/config.js +18 -2
  38. package/dist/core/events.js +3 -7
  39. package/dist/core/logs-db.js +6 -63
  40. package/dist/core/state/migrations.js +714 -0
  41. package/dist/core/state-db.js +28 -779
  42. package/dist/indexer/db/db.js +82 -216
  43. package/dist/indexer/indexer.js +11 -112
  44. package/dist/indexer/passes/dir-staleness.js +114 -0
  45. package/dist/indexer/search/search-source.js +10 -24
  46. package/dist/indexer/search/semantic-status.js +4 -0
  47. package/dist/integrations/agent/runner-dispatch.js +59 -0
  48. package/dist/llm/client.js +22 -11
  49. package/dist/llm/embedder.js +15 -0
  50. package/dist/llm/embedders/deterministic.js +66 -0
  51. package/dist/llm/graph-extract.js +28 -39
  52. package/dist/llm/memory-infer.js +34 -22
  53. package/dist/llm/metadata-enhance.js +35 -30
  54. package/dist/llm/structured-call.js +49 -0
  55. package/dist/output/shapes/passthrough.js +0 -1
  56. package/dist/registry/providers/skills-sh.js +21 -147
  57. package/dist/registry/providers/static-index.js +15 -157
  58. package/dist/registry/resolve.js +22 -9
  59. package/dist/scripts/migrate-storage.js +892 -1186
  60. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +214 -179
  61. package/dist/setup/setup.js +26 -5
  62. package/dist/sources/providers/filesystem.js +0 -1
  63. package/dist/sources/providers/git-install.js +206 -0
  64. package/dist/sources/providers/git-provider.js +234 -0
  65. package/dist/sources/providers/git-stash.js +248 -0
  66. package/dist/sources/providers/git.js +10 -671
  67. package/dist/sources/providers/npm.js +2 -6
  68. package/dist/sources/providers/sync-from-ref.js +9 -1
  69. package/dist/sources/providers/website.js +2 -3
  70. package/dist/sources/website-ingest.js +51 -9
  71. package/dist/sources/wiki-fetchers/registry.js +53 -0
  72. package/dist/sources/wiki-fetchers/youtube.js +185 -0
  73. package/dist/storage/database.js +45 -10
  74. package/dist/storage/managed-db.js +82 -0
  75. package/dist/storage/repositories/registry-cache.js +92 -0
  76. package/dist/tasks/runner.js +5 -13
  77. package/dist/workflows/runtime/runs.js +1 -117
  78. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  79. package/package.json +5 -5
  80. package/dist/commands/db-cli.js +0 -23
  81. package/dist/indexer/db/db-backup.js +0 -376
@@ -9,8 +9,8 @@
9
9
  * transport client in `client.ts`.
10
10
  */
11
11
  import metadataEnhanceSystemPrompt from "../assets/prompts/metadata-enhance-system.md" with { type: "text" };
12
- import { chatCompletion, parseJsonResponse } from "./client.js";
13
- import { tryLlmFeature } from "./feature-gate.js";
12
+ import { parseJsonResponse } from "./client.js";
13
+ import { callStructured } from "./structured-call.js";
14
14
  const SYSTEM_PROMPT = metadataEnhanceSystemPrompt;
15
15
  /**
16
16
  * Use an LLM to enhance a stash entry's metadata: improve description,
@@ -41,34 +41,39 @@ Generate improved metadata for this ${entry.type}. Return JSON with these fields
41
41
  - "tags": an array of 3-8 relevant keyword tags
42
42
 
43
43
  Return ONLY the JSON object, no explanation.`;
44
- const runLlm = async () => {
45
- const raw = await chatCompletion(config, [
44
+ // `parse` owns the raw response: the `!raw`/unparseable case ⇒ `{}`, plus the
45
+ // description/searchHints/tags shaping. `enhanceMetadata` never warns and
46
+ // never bumps telemetry, so `onError` (gated path only) just swallows to `{}`
47
+ // — identical to the surrounding control flow's pre-migration behaviour. The
48
+ // ungated path (akmConfig === undefined) propagates errors via callStructured.
49
+ return callStructured({
50
+ feature: "metadata_enhance",
51
+ akmConfig,
52
+ config,
53
+ messages: [
46
54
  { role: "system", content: SYSTEM_PROMPT },
47
55
  { role: "user", content: userPrompt },
48
- ], { signal });
49
- const parsed = parseJsonResponse(raw);
50
- if (!parsed)
51
- return {};
52
- const result = {};
53
- if (typeof parsed.description === "string" && parsed.description) {
54
- result.description = parsed.description;
55
- }
56
- if (Array.isArray(parsed.searchHints)) {
57
- result.searchHints = parsed.searchHints
58
- .filter((s) => typeof s === "string" && s.trim().length > 0)
59
- .slice(0, 8);
60
- }
61
- if (Array.isArray(parsed.tags)) {
62
- result.tags = parsed.tags.filter((s) => typeof s === "string" && s.trim().length > 0).slice(0, 10);
63
- }
64
- return result;
65
- };
66
- // When no akmConfig is provided, bypass the feature gate entirely: run the
67
- // LLM call directly and let errors propagate to the caller (pre-gate
68
- // behaviour). When akmConfig is present, honour the feature flag and swallow
69
- // errors to {} via tryLlmFeature.
70
- if (akmConfig === undefined) {
71
- return runLlm();
72
- }
73
- return tryLlmFeature("metadata_enhance", akmConfig, runLlm, {}, { timeoutMs: config.timeoutMs });
56
+ ],
57
+ request: { signal, timeoutMs: config.timeoutMs },
58
+ parse: (raw) => {
59
+ const parsed = raw ? parseJsonResponse(raw) : undefined;
60
+ if (!parsed)
61
+ return {};
62
+ const result = {};
63
+ if (typeof parsed.description === "string" && parsed.description) {
64
+ result.description = parsed.description;
65
+ }
66
+ if (Array.isArray(parsed.searchHints)) {
67
+ result.searchHints = parsed.searchHints
68
+ .filter((s) => typeof s === "string" && s.trim().length > 0)
69
+ .slice(0, 8);
70
+ }
71
+ if (Array.isArray(parsed.tags)) {
72
+ result.tags = parsed.tags.filter((s) => typeof s === "string" && s.trim().length > 0).slice(0, 10);
73
+ }
74
+ return result;
75
+ },
76
+ onError: () => ({}),
77
+ fallback: {},
78
+ });
74
79
  }
@@ -0,0 +1,49 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { chatCompletion, isContextSizeError, LlmCallError } from "./client.js";
5
+ import { tryLlmFeature } from "./feature-gate.js";
6
+ /**
7
+ * Classify a thrown LLM error into one of the three buckets. This is the single
8
+ * home for the `isContextSizeError -> html -> other` ladder that was previously
9
+ * inlined at every call site.
10
+ */
11
+ export function classifyLlmError(err) {
12
+ const message = err instanceof Error ? err.message : String(err);
13
+ if (isContextSizeError(message))
14
+ return "context_limit";
15
+ if (err instanceof LlmCallError && err.code === "provider_html_error")
16
+ return "html";
17
+ return "other";
18
+ }
19
+ export async function callStructured(opts) {
20
+ const { feature, akmConfig, config, messages, request, parse, onError, fallback, onFallback } = opts;
21
+ const chat = request?.chat ?? chatCompletion;
22
+ const chatOptions = {
23
+ temperature: request?.temperature,
24
+ timeoutMs: request?.timeoutMs,
25
+ signal: request?.signal,
26
+ responseSchema: request?.responseSchema,
27
+ onRetryAttempt: request?.onRetryAttempt,
28
+ };
29
+ // UNGATED: run the chat+parse directly. Errors propagate — no `onError`
30
+ // funnel — matching the pre-gate behaviour of direct callers.
31
+ if (akmConfig === undefined) {
32
+ const raw = await chat(config, messages, chatOptions);
33
+ return parse(raw);
34
+ }
35
+ // GATED: run through `tryLlmFeature`. A throw inside is classified ONCE and
36
+ // routed to `onError`; `tryLlmFeature` returns `fallback` on disablement/timeout.
37
+ return tryLlmFeature(feature, akmConfig, async () => {
38
+ try {
39
+ const raw = await chat(config, messages, chatOptions);
40
+ return parse(raw);
41
+ }
42
+ catch (err) {
43
+ return onError(classifyLlmError(err), err);
44
+ }
45
+ }, fallback, {
46
+ timeoutMs: request?.timeoutMs,
47
+ onFallback,
48
+ });
49
+ }
@@ -23,7 +23,6 @@ const PASSTHROUGH_COMMANDS = [
23
23
  "agent-result",
24
24
  "clone",
25
25
  "config",
26
- "db-backups",
27
26
  "disable",
28
27
  "enable",
29
28
  "env-create",
@@ -2,46 +2,12 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { fetchWithRetry } from "../../core/common.js";
5
- import { rethrowIfTestIsolationError } from "../../core/errors.js";
6
- import { closeDatabase, getRegistryIndexCache, openDatabase, upsertRegistryIndexCache } from "../../indexer/db/db.js";
7
5
  import { md5Hex } from "../../runtime.js";
6
+ import { fetchCachedJson } from "../../storage/repositories/registry-cache.js";
8
7
  import { registerProvider } from "../factory.js";
9
8
  // ── Constants ───────────────────────────────────────────────────────────────
10
9
  /** Per-query cache TTL in milliseconds (15 minutes). */
11
10
  const QUERY_CACHE_TTL_MS = 15 * 60 * 1000;
12
- // ── Cache DB lifecycle ────────────────────────────────────────────────────────
13
- /**
14
- * RAII-style lifecycle helper for the registry cache DB. Opens the DB (treating
15
- * a failed open exactly like the legacy fall-through: the bun-test isolation
16
- * guard is re-thrown, any other failure yields `db = undefined`), runs `fn`,
17
- * and guarantees the DB is closed in a `finally` after `fn` has fully settled
18
- * (the await is required: the callbacks are async, and closing before they
19
- * settle would tear the DB down mid-write).
20
- */
21
- async function withRegistryCacheDb(fn) {
22
- let db;
23
- try {
24
- db = openDatabase();
25
- }
26
- catch (err) {
27
- // Never mask the bun-test isolation guard as "DB unavailable".
28
- rethrowIfTestIsolationError(err);
29
- db = undefined;
30
- }
31
- try {
32
- return await fn(db);
33
- }
34
- finally {
35
- if (db) {
36
- try {
37
- closeDatabase(db);
38
- }
39
- catch {
40
- /* ignore */
41
- }
42
- }
43
- }
44
- }
45
11
  // ── Provider class ──────────────────────────────────────────────────────────
46
12
  class SkillsShProvider {
47
13
  type = "skills-sh";
@@ -66,131 +32,39 @@ class SkillsShProvider {
66
32
  return { hits: [], warnings: [`Registry ${label}: ${message}`] };
67
33
  }
68
34
  }
69
- // ── v1-spec §3.1 surface ────────────────────────────────────────────────
70
- async searchKits(q) {
71
- const result = await this.search({
72
- query: q.text,
73
- limit: q.limit ?? 20,
74
- includeAssets: false,
75
- });
76
- return result.hits.map((hit) => ({
77
- id: hit.id,
78
- title: hit.title,
79
- summary: hit.description,
80
- installRef: hit.installRef,
81
- score: hit.score,
82
- }));
83
- }
84
- async searchAssets(q) {
85
- const result = await this.search({
86
- query: q.text,
87
- limit: q.limit ?? 20,
88
- includeAssets: true,
89
- });
90
- return (result.assetHits ?? []).map((hit) => ({
91
- kitId: hit.stash.id,
92
- type: hit.assetType,
93
- name: hit.assetName,
94
- summary: hit.description,
95
- cloneRef: hit.action.replace(/^akm add\s+/, ""),
96
- }));
97
- }
98
- /**
99
- * skills.sh has no `getKit` API — every entry corresponds to a GitHub
100
- * repository whose metadata we already include in the search result. We
101
- * synthesize a manifest from the search hit when the caller knows the stash
102
- * id; if not present in the most recent results, return null.
103
- */
104
- async getKit(id) {
105
- if (!id.startsWith("skills-sh:"))
106
- return null;
107
- const slug = id.slice("skills-sh:".length);
108
- // Best-effort: the API gives us search-by-name; extract the leaf segment.
109
- const segments = slug.split("/").filter(Boolean);
110
- const leaf = segments[segments.length - 1] ?? slug;
111
- const result = await this.search({ query: leaf, limit: 50, includeAssets: false });
112
- const match = result.hits.find((hit) => hit.id === id);
113
- if (!match)
114
- return null;
115
- return { id: match.id, installRef: match.installRef };
116
- }
117
- /**
118
- * skills.sh entries are always GitHub repositories. Claim only refs whose
119
- * parsed source is `github`; defer everything else (npm tarballs, local
120
- * paths, raw git URLs) to other registries.
121
- */
122
- canHandle(ref) {
123
- return ref.source === "github";
124
- }
125
35
  async fetchSkills(query, limit) {
126
36
  // Build a stable DB cache key for this query
127
37
  const dbCacheKey = this.queryDbCacheKey(query, limit);
128
- return withRegistryCacheDb(async (db) => {
129
- // ── Step 1: Try DB cache (index.db) ───────────────────────────────────
130
- let dbCacheResult;
131
- try {
132
- if (db) {
133
- dbCacheResult = getRegistryIndexCache(db, dbCacheKey, QUERY_CACHE_TTL_MS);
134
- }
135
- }
136
- catch (err) {
137
- // Never mask the bun-test isolation guard as "DB unavailable" — see
138
- // rethrowIfTestIsolationError in src/core/errors.ts. Without this,
139
- // a leaky test silently gets a cold cache + fresh fetch instead of
140
- // the loud TEST_ISOLATION_MISSING failure the guard intends.
141
- rethrowIfTestIsolationError(err);
142
- // index.db not available yet (pre-migration install or test env) — fall through
143
- }
144
- if (dbCacheResult) {
38
+ const baseUrl = this.config.url.replace(/\/+$/, "");
39
+ const url = `${baseUrl}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`;
40
+ return fetchCachedJson({
41
+ cacheKey: dbCacheKey,
42
+ ttlMs: QUERY_CACHE_TTL_MS,
43
+ // A fresh hit returns even an empty array; a stale fallback only when
44
+ // non-empty. Corrupt cache JSON is swallowed and treated as a miss.
45
+ parseCache: (json, { stale }) => {
145
46
  try {
146
- const parsed = JSON.parse(dbCacheResult.indexJson);
147
- if (Array.isArray(parsed)) {
148
- const entries = parsed.filter(isValidSkillsEntry);
149
- return entries;
150
- }
47
+ const parsed = JSON.parse(json);
48
+ if (!Array.isArray(parsed))
49
+ return undefined;
50
+ const entries = parsed.filter(isValidSkillsEntry);
51
+ if (stale && entries.length === 0)
52
+ return undefined;
53
+ return entries;
151
54
  }
152
55
  catch {
153
- /* corrupt DB entry — fall through */
56
+ return undefined;
154
57
  }
155
- }
156
- // ── Step 2: Fetch from API ─────────────────────────────────────────────
157
- const baseUrl = this.config.url.replace(/\/+$/, "");
158
- const url = `${baseUrl}/api/search?q=${encodeURIComponent(query)}&limit=${limit}`;
159
- try {
58
+ },
59
+ fetchFresh: async () => {
160
60
  const response = await fetchWithRetry(url, undefined, { timeout: 10_000, retries: 1 });
161
61
  if (!response.ok) {
162
62
  throw new Error(`HTTP ${response.status}`);
163
63
  }
164
64
  const data = (await response.json());
165
65
  const entries = parseSkillsResponse(data);
166
- // Write to DB cache (primary)
167
- if (db) {
168
- try {
169
- upsertRegistryIndexCache(db, dbCacheKey, JSON.stringify(entries));
170
- }
171
- catch {
172
- /* best-effort */
173
- }
174
- }
175
- return entries;
176
- }
177
- catch (err) {
178
- // Fetch failed — use stale DB cache if available
179
- if (dbCacheResult) {
180
- try {
181
- const parsed = JSON.parse(dbCacheResult.indexJson);
182
- if (Array.isArray(parsed)) {
183
- const entries = parsed.filter(isValidSkillsEntry);
184
- if (entries.length > 0)
185
- return entries;
186
- }
187
- }
188
- catch {
189
- /* ignore */
190
- }
191
- }
192
- throw err;
193
- }
66
+ return { value: entries, cacheJson: JSON.stringify(entries) };
67
+ },
194
68
  });
195
69
  }
196
70
  mapToHits(entries) {
@@ -2,10 +2,10 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { fetchWithRetry, jsonWithByteCap, toErrorMessage } from "../../core/common.js";
5
- import { rethrowIfTestIsolationError } from "../../core/errors.js";
6
- import { closeDatabase, getRegistryIndexCache, openDatabase, upsertRegistryIndexCache } from "../../indexer/db/db.js";
7
5
  import { asString } from "../../integrations/github.js";
6
+ import { fetchCachedJson } from "../../storage/repositories/registry-cache.js";
8
7
  import { registerProvider } from "../factory.js";
8
+ import { buildInstallRef } from "../resolve.js";
9
9
  // ── Constants ───────────────────────────────────────────────────────────────
10
10
  /** Cache TTL in milliseconds (1 hour). */
11
11
  const CACHE_TTL_MS = 60 * 60 * 1000;
@@ -30,51 +30,6 @@ class StaticIndexProvider {
30
30
  }
31
31
  return { hits, assetHits, warnings: warnings.length > 0 ? warnings : undefined };
32
32
  }
33
- // ── v1-spec §3.1 surface ────────────────────────────────────────────────
34
- async searchKits(q) {
35
- const result = await this.search({
36
- query: q.text,
37
- limit: q.limit ?? 20,
38
- includeAssets: false,
39
- });
40
- return result.hits.map(hitToKitResult);
41
- }
42
- async searchAssets(q) {
43
- const result = await this.search({
44
- query: q.text,
45
- limit: q.limit ?? 20,
46
- includeAssets: true,
47
- });
48
- return (result.assetHits ?? []).map(assetHitToPreview);
49
- }
50
- async getKit(id) {
51
- const allKits = await this.loadAllKits([]);
52
- const found = allKits.find(({ stash }) => stash.id === id);
53
- if (!found)
54
- return null;
55
- const installRef = buildInstallRef(found.stash.source, found.stash.ref);
56
- return {
57
- id: found.stash.id,
58
- installRef,
59
- assets: found.stash.assets?.map((asset) => ({
60
- kitId: found.stash.id,
61
- type: asset.type,
62
- name: asset.name,
63
- summary: asset.description,
64
- cloneRef: installRef,
65
- })),
66
- };
67
- }
68
- /**
69
- * Static-index doesn't own a URL prefix — any `ParsedRegistryRef` could
70
- * theoretically be backed by an entry in some static-index registry. We
71
- * therefore claim every ref. The orchestrator picks the first matching
72
- * provider, and `static-index` is registered first by `index.ts`, so this
73
- * is effectively the default catch-all.
74
- */
75
- canHandle(_ref) {
76
- return true;
77
- }
78
33
  // ── Internals ───────────────────────────────────────────────────────────
79
34
  async loadAllKits(warnings) {
80
35
  const allKits = [];
@@ -94,84 +49,17 @@ class StaticIndexProvider {
94
49
  return allKits;
95
50
  }
96
51
  }
97
- function hitToKitResult(hit) {
98
- return {
99
- id: hit.id,
100
- title: hit.title,
101
- summary: hit.description,
102
- installRef: hit.installRef,
103
- score: hit.score,
104
- };
105
- }
106
- function assetHitToPreview(hit) {
107
- return {
108
- kitId: hit.stash.id,
109
- type: hit.assetType,
110
- name: hit.assetName,
111
- summary: hit.description,
112
- cloneRef: hit.action.replace(/^akm add\s+/, ""),
113
- };
114
- }
115
52
  // ── Self-register ───────────────────────────────────────────────────────────
116
53
  registerProvider("static-index", (config) => new StaticIndexProvider(config));
117
54
  // ── Index loading with cache ────────────────────────────────────────────────
118
- /**
119
- * RAII-style lifecycle helper for the registry cache DB. Opens the DB (treating
120
- * a failed open exactly like the legacy fall-through: the bun-test isolation
121
- * guard is re-thrown, any other failure yields `db = undefined`), runs `fn`,
122
- * and guarantees the DB is closed in a `finally` after `fn` has fully settled
123
- * (the await is required: the callbacks are async, and closing before they
124
- * settle would tear the DB down mid-write).
125
- */
126
- async function withRegistryCacheDb(fn) {
127
- let db;
128
- try {
129
- db = openDatabase();
130
- }
131
- catch (err) {
132
- // Never mask the bun-test isolation guard as "DB unavailable".
133
- rethrowIfTestIsolationError(err);
134
- db = undefined;
135
- }
136
- try {
137
- return await fn(db);
138
- }
139
- finally {
140
- if (db) {
141
- try {
142
- closeDatabase(db);
143
- }
144
- catch {
145
- /* ignore */
146
- }
147
- }
148
- }
149
- }
150
55
  async function loadIndex(entry) {
151
- return withRegistryCacheDb(async (db) => {
152
- // ── Step 1: Try DB cache (index.db) ─────────────────────────────────────
153
- let dbCacheResult;
154
- try {
155
- if (db) {
156
- dbCacheResult = getRegistryIndexCache(db, entry.url, CACHE_TTL_MS);
157
- }
158
- }
159
- catch (err) {
160
- // Never mask the bun-test isolation guard as "DB unavailable" — see
161
- // rethrowIfTestIsolationError in src/core/errors.ts. Without this, a
162
- // leaky test silently gets a cold cache instead of the loud
163
- // TEST_ISOLATION_MISSING failure the guard intends.
164
- rethrowIfTestIsolationError(err);
165
- // index.db read failed (pre-migration install or test env) — fall through
166
- }
167
- if (dbCacheResult) {
168
- const index = parseRegistryIndex(JSON.parse(dbCacheResult.indexJson));
169
- if (index) {
170
- return index;
171
- }
172
- }
173
- // ── Step 2: Fetch fresh index from remote ────────────────────────────────
174
- try {
56
+ return fetchCachedJson({
57
+ cacheKey: entry.url,
58
+ ttlMs: CACHE_TTL_MS,
59
+ // Both the fresh hit and the stale fallback parse identically; a corrupt
60
+ // cache row lets JSON.parse throw out of the load (legacy behaviour).
61
+ parseCache: (json) => parseRegistryIndex(JSON.parse(json)) ?? undefined,
62
+ fetchFresh: async () => {
175
63
  const response = await fetchWithRetry(entry.url, undefined, { timeout: 10_000 });
176
64
  if (!response.ok) {
177
65
  throw new Error(`HTTP ${response.status}`);
@@ -180,31 +68,13 @@ async function loadIndex(entry) {
180
68
  // responses from a compromised server would OOM us.
181
69
  const data = await jsonWithByteCap(response, 50 * 1024 * 1024);
182
70
  const index = parseRegistryIndex(data);
183
- if (index) {
184
- // Write to DB cache (primary)
185
- if (db) {
186
- try {
187
- const etag = response.headers.get("etag") ?? undefined;
188
- const lastModified = response.headers.get("last-modified") ?? undefined;
189
- upsertRegistryIndexCache(db, entry.url, JSON.stringify(index), { etag, lastModified });
190
- }
191
- catch {
192
- /* best-effort */
193
- }
194
- }
195
- return index;
196
- }
197
- throw new Error("Invalid registry index format");
198
- }
199
- catch (err) {
200
- // Fetch failed — use stale DB cache if available
201
- if (dbCacheResult) {
202
- const index = parseRegistryIndex(JSON.parse(dbCacheResult.indexJson));
203
- if (index)
204
- return index;
71
+ if (!index) {
72
+ throw new Error("Invalid registry index format");
205
73
  }
206
- throw err;
207
- }
74
+ const etag = response.headers.get("etag") ?? undefined;
75
+ const lastModified = response.headers.get("last-modified") ?? undefined;
76
+ return { value: index, cacheJson: JSON.stringify(index), cacheOpts: { etag, lastModified } };
77
+ },
208
78
  });
209
79
  }
210
80
  export function isCacheExpired(mtimeMs) {
@@ -431,15 +301,3 @@ function asStringArray(value) {
431
301
  const filtered = value.filter((v) => typeof v === "string");
432
302
  return filtered.length > 0 ? filtered : undefined;
433
303
  }
434
- function buildInstallRef(source, ref) {
435
- switch (source) {
436
- case "npm":
437
- return `npm:${ref}`;
438
- case "git":
439
- return `git+${ref}`;
440
- case "local":
441
- return `file:${ref}`;
442
- default:
443
- return `github:${ref}`;
444
- }
445
- }
@@ -70,6 +70,19 @@ export function parseRegistryRef(rawRef) {
70
70
  }
71
71
  return parseGithubShorthand(ref, ref);
72
72
  }
73
+ /** Inverse of {@link parseRegistryRef}: build the install ref for a source kind. */
74
+ export function buildInstallRef(source, ref) {
75
+ switch (source) {
76
+ case "npm":
77
+ return `npm:${ref}`;
78
+ case "git":
79
+ return `git+${ref}`;
80
+ case "local":
81
+ return `file:${ref}`;
82
+ case "github":
83
+ return `github:${ref}`;
84
+ }
85
+ }
73
86
  /**
74
87
  * Known prefixes that `parseRegistryRef` handles as installable sources.
75
88
  * Anything with a colon that doesn't start with one of these is likely a
@@ -105,16 +118,16 @@ function detectRegistrySearchId(ref) {
105
118
  return lines.join("\n");
106
119
  }
107
120
  export async function resolveRegistryArtifact(parsed) {
108
- if (parsed.source === "npm") {
109
- return resolveNpmArtifact(parsed);
110
- }
111
- if (parsed.source === "local") {
112
- return resolveLocalArtifact(parsed);
113
- }
114
- if (parsed.source === "git") {
115
- return resolveGitArtifact(parsed);
121
+ switch (parsed.source) {
122
+ case "npm":
123
+ return resolveNpmArtifact(parsed);
124
+ case "local":
125
+ return resolveLocalArtifact(parsed);
126
+ case "git":
127
+ return resolveGitArtifact(parsed);
128
+ case "github":
129
+ return resolveGithubArtifact(parsed);
116
130
  }
117
- return resolveGithubArtifact(parsed);
118
131
  }
119
132
  function parseNpmRef(input, originalRef) {
120
133
  const trimmed = input.trim();