akm-cli 0.9.0-beta.5 → 0.9.0-beta.51

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 (221) hide show
  1. package/CHANGELOG.md +711 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/profiles/default.json +9 -4
  6. package/dist/assets/profiles/frequent.json +1 -1
  7. package/dist/assets/profiles/memory-focus.json +1 -1
  8. package/dist/assets/profiles/quick.json +1 -1
  9. package/dist/assets/profiles/synthesize.json +15 -0
  10. package/dist/assets/profiles/thorough.json +1 -1
  11. package/dist/assets/prompts/consolidate-system.md +23 -0
  12. package/dist/assets/prompts/contradiction-judge.md +33 -0
  13. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  14. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  15. package/dist/assets/prompts/extract-session.md +6 -2
  16. package/dist/assets/prompts/graph-extract-system.md +1 -0
  17. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  18. package/dist/assets/prompts/memory-infer-system.md +1 -0
  19. package/dist/assets/prompts/memory-infer-user.md +5 -0
  20. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  21. package/dist/assets/prompts/procedural-system.md +44 -0
  22. package/dist/assets/prompts/recombine-system.md +40 -0
  23. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  24. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  25. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  26. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  34. package/dist/assets/templates/html/health.html +281 -111
  35. package/dist/assets/wiki/ingest-workflow-template.md +38 -10
  36. package/dist/cli/parse-args.js +46 -1
  37. package/dist/cli/shared.js +28 -0
  38. package/dist/cli.js +27 -11
  39. package/dist/commands/agent/agent-dispatch.js +2 -2
  40. package/dist/commands/agent/agent-support.js +0 -7
  41. package/dist/commands/agent/contribute-cli.js +17 -4
  42. package/dist/commands/config-cli.js +18 -2
  43. package/dist/commands/env/child-env.js +47 -0
  44. package/dist/commands/env/env-cli.js +33 -26
  45. package/dist/commands/env/secret-cli.js +36 -22
  46. package/dist/commands/feedback-cli.js +15 -6
  47. package/dist/commands/graph/graph-cli.js +5 -13
  48. package/dist/commands/graph/graph.js +76 -72
  49. package/dist/commands/health/checks.js +49 -1
  50. package/dist/commands/health/html-report.js +422 -80
  51. package/dist/commands/health.js +386 -9
  52. package/dist/commands/improve/calibration.js +161 -0
  53. package/dist/commands/improve/consolidate/chunking.js +141 -0
  54. package/dist/commands/improve/consolidate/eligibility.js +81 -0
  55. package/dist/commands/improve/consolidate/merge.js +145 -0
  56. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  57. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  58. package/dist/commands/improve/consolidate.js +635 -660
  59. package/dist/commands/improve/dedup.js +482 -0
  60. package/dist/commands/improve/distill.js +159 -69
  61. package/dist/commands/improve/eligibility.js +434 -0
  62. package/dist/commands/improve/encoding-salience.js +205 -0
  63. package/dist/commands/improve/extract-cli.js +124 -2
  64. package/dist/commands/improve/extract-prompt.js +39 -2
  65. package/dist/commands/improve/extract-watch.js +140 -0
  66. package/dist/commands/improve/extract.js +389 -40
  67. package/dist/commands/improve/feedback-valence.js +54 -0
  68. package/dist/commands/improve/homeostatic.js +467 -0
  69. package/dist/commands/improve/improve-auto-accept.js +138 -7
  70. package/dist/commands/improve/improve-cli.js +36 -61
  71. package/dist/commands/improve/improve-profiles.js +14 -0
  72. package/dist/commands/improve/improve-result-file.js +14 -25
  73. package/dist/commands/improve/improve-session.js +58 -0
  74. package/dist/commands/improve/improve.js +485 -2498
  75. package/dist/commands/improve/locks.js +154 -0
  76. package/dist/commands/improve/loop-stages.js +1083 -0
  77. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  78. package/dist/commands/improve/outcome-loop.js +256 -0
  79. package/dist/commands/improve/preparation.js +1966 -0
  80. package/dist/commands/improve/proactive-maintenance.js +115 -0
  81. package/dist/commands/improve/procedural.js +418 -0
  82. package/dist/commands/improve/recombine.js +850 -0
  83. package/dist/commands/improve/reflect-noise.js +0 -0
  84. package/dist/commands/improve/reflect.js +183 -40
  85. package/dist/commands/improve/salience.js +438 -0
  86. package/dist/commands/improve/triage.js +93 -0
  87. package/dist/commands/lint/agent-linter.js +19 -24
  88. package/dist/commands/lint/base-linter.js +173 -60
  89. package/dist/commands/lint/command-linter.js +19 -24
  90. package/dist/commands/lint/env-key-rules.js +38 -1
  91. package/dist/commands/lint/fact-linter.js +39 -0
  92. package/dist/commands/lint/index.js +31 -13
  93. package/dist/commands/lint/memory-linter.js +1 -1
  94. package/dist/commands/lint/registry.js +7 -2
  95. package/dist/commands/lint/task-linter.js +3 -3
  96. package/dist/commands/lint/workflow-linter.js +26 -1
  97. package/dist/commands/proposal/drain-policies.js +5 -0
  98. package/dist/commands/proposal/drain.js +43 -50
  99. package/dist/commands/proposal/proposal-cli.js +21 -31
  100. package/dist/commands/proposal/proposal.js +5 -0
  101. package/dist/commands/proposal/propose.js +7 -2
  102. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  103. package/dist/commands/proposal/validators/proposals.js +189 -63
  104. package/dist/commands/read/curate.js +414 -94
  105. package/dist/commands/read/knowledge.js +6 -3
  106. package/dist/commands/read/search-cli.js +9 -4
  107. package/dist/commands/read/search.js +10 -6
  108. package/dist/commands/read/show.js +86 -7
  109. package/dist/commands/sources/init.js +49 -17
  110. package/dist/commands/sources/installed-stashes.js +11 -3
  111. package/dist/commands/sources/schema-repair.js +43 -45
  112. package/dist/commands/sources/self-update.js +2 -2
  113. package/dist/commands/sources/source-add.js +7 -3
  114. package/dist/commands/sources/stash-cli.js +28 -40
  115. package/dist/commands/sources/stash-skeleton.js +23 -8
  116. package/dist/commands/tasks/tasks-cli.js +19 -27
  117. package/dist/commands/tasks/tasks.js +39 -11
  118. package/dist/commands/wiki-cli.js +21 -35
  119. package/dist/core/asset/asset-registry.js +3 -1
  120. package/dist/core/asset/asset-spec.js +18 -2
  121. package/dist/core/asset/frontmatter.js +166 -167
  122. package/dist/core/asset/markdown.js +8 -0
  123. package/dist/core/authoring-rules.js +92 -0
  124. package/dist/core/common.js +0 -5
  125. package/dist/core/config/config-migration.js +12 -11
  126. package/dist/core/config/config-schema.js +340 -56
  127. package/dist/core/config/config-types.js +3 -3
  128. package/dist/core/config/config.js +28 -7
  129. package/dist/core/events.js +3 -7
  130. package/dist/core/improve-types.js +11 -8
  131. package/dist/core/logs-db.js +10 -66
  132. package/dist/core/parse.js +36 -16
  133. package/dist/core/paths.js +3 -0
  134. package/dist/core/standards/resolve-standards-context.js +87 -0
  135. package/dist/core/standards/resolve-stash-standards.js +99 -0
  136. package/dist/core/standards/resolve-type-conventions.js +66 -0
  137. package/dist/core/state/migrations.js +714 -0
  138. package/dist/core/state-db.js +525 -474
  139. package/dist/indexer/db/db.js +439 -247
  140. package/dist/indexer/db/graph-db.js +129 -86
  141. package/dist/indexer/ensure-index.js +152 -17
  142. package/dist/indexer/graph/graph-boost.js +51 -41
  143. package/dist/indexer/graph/graph-extraction.js +218 -4
  144. package/dist/indexer/index-writer-lock.js +99 -0
  145. package/dist/indexer/indexer.js +123 -221
  146. package/dist/indexer/passes/dir-staleness.js +114 -0
  147. package/dist/indexer/passes/memory-inference.js +13 -5
  148. package/dist/indexer/passes/staleness-detect.js +2 -5
  149. package/dist/indexer/search/db-search.js +19 -6
  150. package/dist/indexer/search/ranking-contributors.js +22 -0
  151. package/dist/indexer/search/ranking.js +4 -0
  152. package/dist/indexer/search/search-source.js +17 -18
  153. package/dist/indexer/search/semantic-status.js +4 -0
  154. package/dist/indexer/walk/matchers.js +9 -0
  155. package/dist/integrations/agent/config.js +6 -53
  156. package/dist/integrations/agent/index.js +2 -18
  157. package/dist/integrations/agent/prompts.js +75 -9
  158. package/dist/integrations/agent/runner-dispatch.js +59 -0
  159. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  160. package/dist/integrations/harnesses/index.js +2 -3
  161. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  162. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  163. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +0 -2
  164. package/dist/integrations/session-logs/index.js +16 -0
  165. package/dist/llm/client.js +45 -15
  166. package/dist/llm/embedder.js +42 -3
  167. package/dist/llm/embedders/deterministic.js +66 -0
  168. package/dist/llm/embedders/local.js +66 -2
  169. package/dist/llm/feature-gate.js +8 -4
  170. package/dist/llm/graph-extract.js +67 -44
  171. package/dist/llm/memory-infer-impl.js +138 -0
  172. package/dist/llm/memory-infer.js +1 -127
  173. package/dist/llm/metadata-enhance.js +44 -31
  174. package/dist/llm/structured-call.js +49 -0
  175. package/dist/migrate-storage-node.mjs +8 -0
  176. package/dist/output/context.js +5 -5
  177. package/dist/output/renderers.js +74 -2
  178. package/dist/output/shapes/curate.js +14 -2
  179. package/dist/output/shapes/passthrough.js +0 -1
  180. package/dist/output/text/helpers.js +16 -1
  181. package/dist/registry/providers/skills-sh.js +21 -147
  182. package/dist/registry/providers/static-index.js +15 -157
  183. package/dist/registry/resolve.js +22 -9
  184. package/dist/runtime.js +25 -1
  185. package/dist/scripts/migrate-storage.js +2617 -1961
  186. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +759 -510
  187. package/dist/setup/setup.js +29 -8
  188. package/dist/sources/include.js +6 -2
  189. package/dist/sources/providers/filesystem.js +0 -1
  190. package/dist/sources/providers/git-install.js +210 -0
  191. package/dist/sources/providers/git-provider.js +234 -0
  192. package/dist/sources/providers/git-stash.js +248 -0
  193. package/dist/sources/providers/git.js +10 -661
  194. package/dist/sources/providers/npm.js +2 -6
  195. package/dist/sources/providers/provider-utils.js +13 -7
  196. package/dist/sources/providers/sync-from-ref.js +9 -1
  197. package/dist/sources/providers/tar-utils.js +16 -8
  198. package/dist/sources/providers/website.js +9 -5
  199. package/dist/sources/website-ingest.js +187 -29
  200. package/dist/sources/wiki-fetchers/registry.js +53 -0
  201. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  202. package/dist/storage/database.js +45 -10
  203. package/dist/storage/managed-db.js +82 -0
  204. package/dist/storage/repositories/registry-cache.js +92 -0
  205. package/dist/storage/sqlite-pragmas.js +146 -0
  206. package/dist/tasks/backends/cron.js +1 -1
  207. package/dist/tasks/backends/launchd.js +1 -1
  208. package/dist/tasks/backends/schtasks.js +1 -1
  209. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  210. package/dist/tasks/runner.js +5 -13
  211. package/dist/text-import-hook.mjs +0 -0
  212. package/dist/wiki/wiki.js +37 -0
  213. package/dist/workflows/db.js +3 -4
  214. package/dist/workflows/runtime/runs.js +1 -117
  215. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  216. package/dist/workflows/validate-summary.js +2 -7
  217. package/docs/data-and-telemetry.md +3 -2
  218. package/docs/migration/release-notes/0.9.0.md +39 -0
  219. package/package.json +13 -11
  220. package/dist/commands/db-cli.js +0 -23
  221. package/dist/indexer/db/db-backup.js +0 -376
@@ -8,16 +8,23 @@
8
8
  * high-signal set of stash + registry hits and enrich each with the data
9
9
  * needed to act (ref, run, parameters, follow-up command).
10
10
  *
11
- * The exported `akmCurate()` API is the single entry point. Internal
12
- * helpers stay private. Tests can drive the public API or call the smaller
13
- * pure helpers (`curateSearchResults`, `orderCuratedTypes`,
14
- * `deriveCurateFallbackQueries`) by importing them directly.
11
+ * The exported `akmCurate()` API is the single entry point. Internal helpers
12
+ * stay private. Tests can drive the public API or call the smaller pure
13
+ * helpers (`curateSearchResults`, `deriveCurateFallbackQueries`,
14
+ * `mergeCurateSearchResponses`) by importing them directly.
15
15
  */
16
+ import fs from "node:fs";
17
+ import { parseAssetRef } from "../../core/asset/asset-ref.js";
18
+ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
19
+ import { getIndexPassConfig, loadConfig } from "../../core/config/config.js";
16
20
  import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
17
21
  import { appendEvent } from "../../core/events.js";
18
- import { closeDatabase, openExistingDatabase } from "../../indexer/db/db.js";
22
+ import { closeDatabase, computeBodyHash, openExistingDatabase } from "../../indexer/db/db.js";
23
+ import { enqueueGraphExtraction, hasGraphData } from "../../indexer/db/graph-db.js";
24
+ import { findSourceForPath, resolveSourceEntries } from "../../indexer/search/search-source.js";
19
25
  import { insertUsageEvent } from "../../indexer/usage/usage-events.js";
20
26
  import { truncateDescription } from "../../output/shapes.js";
27
+ import { withIndexDb } from "../../storage/repositories/index-db.js";
21
28
  import { akmSearch, parseSearchSource } from "./search.js";
22
29
  import { akmShowUnified } from "./show.js";
23
30
  const CURATE_FALLBACK_FILTER_WORDS = new Set([
@@ -34,13 +41,19 @@ const CURATE_FALLBACK_FILTER_WORDS = new Set([
34
41
  "to",
35
42
  "with",
36
43
  ]);
37
- const CURATED_TYPE_FALLBACK_ORDER = ["skill", "command", "script", "knowledge", "agent", "memory"];
38
- const CURATED_TYPE_FALLBACK_INDEX = new Map(CURATED_TYPE_FALLBACK_ORDER.map((type, index) => [type, index]));
44
+ const CURATE_SHORT_FALLBACK_TOKENS = new Set(["ai", "ci", "cd", "go", "js", "ts"]);
39
45
  const MIN_CURATE_FALLBACK_TOKEN_LENGTH = 3;
40
46
  const MAX_CURATE_FALLBACK_KEYWORDS = 6;
41
47
  export const CURATE_SEARCH_LIMIT_MULTIPLIER = 4;
42
48
  export const MIN_CURATE_SEARCH_LIMIT = 12;
43
49
  const DEFAULT_CURATE_LIMIT = 4;
50
+ const CURATE_CLOSE_SCORE_BAND = 0.12;
51
+ const CURATE_TAIL_SCORE_FLOOR = 0.35;
52
+ const CURATE_RELATIVE_SCORE_FLOOR = 0.7;
53
+ const CURATE_FALLBACK_TOP_SCORE_THRESHOLD = 0.8;
54
+ const CURATE_FALLBACK_STRONG_SCORE_FLOOR = 0.35;
55
+ const MAX_CURATE_SUPPORT_REFS = 2;
56
+ const CURATE_REFERENCE_QUERY_RE = /\b(?:reference|docs?|guide|how|explain|learn|readme|why)\b/;
44
57
  /**
45
58
  * Fire-and-forget: log a curate event to the usage_events table and events.jsonl.
46
59
  * Never blocks the caller; errors are silently ignored.
@@ -63,6 +76,16 @@ function logCurateEvent(query, result) {
63
76
  }),
64
77
  source: "user",
65
78
  });
79
+ for (const item of result.items) {
80
+ if (!("ref" in item) || typeof item.ref !== "string")
81
+ continue;
82
+ insertUsageEvent(db, {
83
+ event_type: "curate",
84
+ query,
85
+ entry_ref: item.ref,
86
+ source: "user",
87
+ });
88
+ }
66
89
  }
67
90
  finally {
68
91
  closeDatabase(db);
@@ -70,13 +93,8 @@ function logCurateEvent(query, result) {
70
93
  }
71
94
  catch (err) {
72
95
  rethrowIfTestIsolationError(err);
73
- /* ignore logging failures */
74
96
  }
75
97
  }
76
- /**
77
- * Public curate entry point. Performs the search itself when
78
- * `options.searchResponse` is not supplied.
79
- */
80
98
  export async function akmCurate(options) {
81
99
  const trimmedQuery = options.query.trim();
82
100
  if (!trimmedQuery) {
@@ -88,8 +106,6 @@ export async function akmCurate(options) {
88
106
  (await searchForCuration({
89
107
  query: options.query,
90
108
  type: options.type,
91
- // Search deeper than the final curated count so we can pick one strong
92
- // match per type and still have room for fallback retries.
93
109
  limit: Math.max(limit * CURATE_SEARCH_LIMIT_MULTIPLIER, MIN_CURATE_SEARCH_LIMIT),
94
110
  source,
95
111
  }));
@@ -101,23 +117,22 @@ export async function curateSearchResults(query, result, limit, selectedType) {
101
117
  const stashHits = result.hits.filter((hit) => hit.type !== "registry");
102
118
  const registryHits = result.registryHits ?? [];
103
119
  let selectedStashHits;
120
+ let supportRefsByRef = new Map();
104
121
  if (selectedType && selectedType !== "any") {
105
122
  selectedStashHits = stashHits.slice(0, limit);
106
123
  }
107
124
  else {
108
- const bestByType = new Map();
109
- for (const hit of stashHits) {
110
- if (!bestByType.has(hit.type))
111
- bestByType.set(hit.type, hit);
112
- }
113
- const orderedTypes = orderCuratedTypes(query, Array.from(bestByType.keys()));
114
- selectedStashHits = orderedTypes
115
- .map((type) => bestByType.get(type))
116
- .filter((hit) => Boolean(hit));
125
+ const selected = selectCuratedStashHits(query, stashHits, limit);
126
+ const preferred = preferBroadRootRepresentative(query, selected.selected, stashHits, selected.supportRefsByRef);
127
+ selectedStashHits = preferred.selected;
128
+ supportRefsByRef = preferred.supportRefsByRef;
117
129
  }
118
130
  const selectedRegistryHits = selectedStashHits.length >= limit ? [] : registryHits.slice(0, Math.min(2, limit - selectedStashHits.length));
131
+ const selectedRefs = new Set(selectedStashHits.map((hit) => hit.ref));
119
132
  const items = [
120
- ...(await Promise.all(selectedStashHits.slice(0, limit).map((hit) => enrichCuratedStashHit(query, hit)))),
133
+ ...(await Promise.all(selectedStashHits
134
+ .slice(0, limit)
135
+ .map((hit) => enrichCuratedStashHit(query, hit, supportRefsByRef.get(hit.ref) ?? [], selectedRefs)))),
121
136
  ...selectedRegistryHits.map((hit) => buildCuratedRegistryItem(query, hit)),
122
137
  ].slice(0, limit);
123
138
  return {
@@ -128,37 +143,7 @@ export async function curateSearchResults(query, result, limit, selectedType) {
128
143
  ...(result.tip ? { tip: result.tip } : {}),
129
144
  };
130
145
  }
131
- export function orderCuratedTypes(query, types) {
132
- const lower = query.toLowerCase();
133
- const boosts = new Map();
134
- const addBoost = (type, amount) => boosts.set(type, (boosts.get(type) ?? 0) + amount);
135
- if (/(run|script|bash|shell|cli|execute|automation|deploy|build|test|lint)/.test(lower)) {
136
- addBoost("script", 6);
137
- addBoost("command", 4);
138
- }
139
- if (/(guide|docs?|readme|reference|how|explain|learn|why)/.test(lower)) {
140
- addBoost("knowledge", 6);
141
- addBoost("skill", 4);
142
- }
143
- if (/(agent|assistant|planner|review|analy[sz]e|architect|prompt)/.test(lower)) {
144
- addBoost("agent", 6);
145
- addBoost("skill", 3);
146
- }
147
- if (/(config|template|release|generate|command)/.test(lower)) {
148
- addBoost("command", 5);
149
- }
150
- if (/(memory|context|recall|remember)/.test(lower)) {
151
- addBoost("memory", 6);
152
- }
153
- return [...types].sort((a, b) => {
154
- const boostDiff = (boosts.get(b) ?? 0) - (boosts.get(a) ?? 0);
155
- if (boostDiff !== 0)
156
- return boostDiff;
157
- return ((CURATED_TYPE_FALLBACK_INDEX.get(a) ?? Number.MAX_SAFE_INTEGER) -
158
- (CURATED_TYPE_FALLBACK_INDEX.get(b) ?? Number.MAX_SAFE_INTEGER));
159
- });
160
- }
161
- async function enrichCuratedStashHit(query, hit) {
146
+ async function enrichCuratedStashHit(query, hit, supportRefs, selectedRefs) {
162
147
  let shown;
163
148
  try {
164
149
  shown = await akmShowUnified({ ref: hit.ref });
@@ -166,8 +151,14 @@ async function enrichCuratedStashHit(query, hit) {
166
151
  catch {
167
152
  shown = undefined;
168
153
  }
154
+ // #624-P3: when lazy graph extraction is opted in, enqueue an ungraphed
155
+ // asset for a later pass to extract. Fire-and-forget, non-blocking, NO inline
156
+ // extraction and NO LLM call here. Default-off (flag unset) = byte-identical.
157
+ if (shown?.path)
158
+ maybeEnqueueLazyGraph(shown.path);
169
159
  const description = shown?.description ?? hit.description;
170
160
  const preview = buildCuratedPreview(shown, hit);
161
+ const mergedSupportRefs = mergeCurateSupportRefs(supportRefs, shown?.related?.hits, selectedRefs, hit.ref);
171
162
  return {
172
163
  source: "stash",
173
164
  type: shown?.type ?? hit.type,
@@ -178,11 +169,50 @@ async function enrichCuratedStashHit(query, hit) {
178
169
  ...(shown?.keys?.length ? { keys: shown.keys } : {}),
179
170
  ...(shown?.parameters?.length ? { parameters: shown.parameters } : {}),
180
171
  ...(shown?.run ? { run: shown.run } : {}),
172
+ ...(mergedSupportRefs.length > 0 ? { supportRefs: mergedSupportRefs } : {}),
181
173
  followUp: `akm show ${hit.ref}`,
182
174
  reason: buildCuratedReason(query, shown?.type ?? hit.type),
183
175
  ...(hit.score !== undefined ? { score: hit.score } : {}),
184
176
  };
185
177
  }
178
+ /**
179
+ * #624-P3 — enqueue an ungraphed asset for lazy graph extraction when the
180
+ * `index.graph.lazyGraphExtraction` flag is on. Pure side-effect, fully
181
+ * best-effort: any failure (config, fs, db) is swallowed so curate never fails
182
+ * on it. NO LLM call and NO inline extraction — only a cheap queue insert.
183
+ * Default-off (flag unset) returns immediately = byte-identical behavior.
184
+ */
185
+ function maybeEnqueueLazyGraph(assetPath) {
186
+ try {
187
+ const config = loadConfig();
188
+ if (getIndexPassConfig(config.index, "graph")?.lazyGraphExtraction !== true)
189
+ return;
190
+ const sources = resolveSourceEntries();
191
+ const source = findSourceForPath(assetPath, sources);
192
+ const stashRoot = source?.path;
193
+ if (!stashRoot)
194
+ return;
195
+ let raw;
196
+ try {
197
+ raw = fs.readFileSync(assetPath, "utf8");
198
+ }
199
+ catch {
200
+ return;
201
+ }
202
+ const body = parseFrontmatter(raw).content.trim();
203
+ if (!body)
204
+ return;
205
+ const bodyHash = computeBodyHash(body);
206
+ withIndexDb((db) => {
207
+ if (!hasGraphData(db, stashRoot, assetPath)) {
208
+ enqueueGraphExtraction(db, stashRoot, assetPath, bodyHash, 0);
209
+ }
210
+ });
211
+ }
212
+ catch (err) {
213
+ rethrowIfTestIsolationError(err);
214
+ }
215
+ }
186
216
  function buildCuratedRegistryItem(query, hit) {
187
217
  return {
188
218
  source: "registry",
@@ -209,19 +239,19 @@ function buildCuratedPreview(shown, hit) {
209
239
  function buildCuratedReason(query, type) {
210
240
  switch (type) {
211
241
  case "script":
212
- return `Best runnable script match for "${query}".`;
242
+ return `Strong runnable script match for "${query}".`;
213
243
  case "command":
214
- return `Best reusable command/template match for "${query}".`;
244
+ return `Strong reusable command/template match for "${query}".`;
215
245
  case "knowledge":
216
- return `Best reference document match for "${query}".`;
246
+ return `Strong reference document match for "${query}".`;
217
247
  case "skill":
218
- return `Best instructions/workflow match for "${query}".`;
248
+ return `Strong instructions/workflow match for "${query}".`;
219
249
  case "agent":
220
- return `Best specialized agent prompt match for "${query}".`;
250
+ return `Strong specialized agent prompt match for "${query}".`;
221
251
  case "memory":
222
- return `Best saved context match for "${query}".`;
252
+ return `Strong saved context match for "${query}".`;
223
253
  default:
224
- return `Best ${type} match for "${query}".`;
254
+ return `Strong ${type} match for "${query}".`;
225
255
  }
226
256
  }
227
257
  function buildCurateSummary(query, items) {
@@ -229,56 +259,108 @@ function buildCurateSummary(query, items) {
229
259
  return `No curated assets were selected for "${query}".`;
230
260
  }
231
261
  const labels = items.map((item) => `${item.type}:${item.name}`);
232
- return `Selected ${items.length} high-signal result${items.length === 1 ? "" : "s"}: ${labels.join(", ")}.`;
262
+ return `Selected ${items.length} curated result${items.length === 1 ? "" : "s"}: ${labels.join(", ")}.`;
233
263
  }
234
- function hasSearchResults(result) {
235
- return result.hits.length > 0 || (result.registryHits?.length ?? 0) > 0;
236
- }
237
- /**
238
- * Extract a small set of fallback keywords when a prompt-style curate query
239
- * returns no hits as a whole phrase.
240
- *
241
- * We keep up to MAX_CURATE_FALLBACK_KEYWORDS distinct keywords and drop short
242
- * or common filler words so follow-up searches stay inexpensive while focusing
243
- * on higher-signal terms.
244
- */
245
264
  export function deriveCurateFallbackQueries(query) {
246
- return Array.from(new Set(query
265
+ const normalizedWhole = query
266
+ .toLowerCase()
267
+ .replace(/[^a-z0-9]+/g, " ")
268
+ .trim();
269
+ const tokens = Array.from(new Set(query
247
270
  .toLowerCase()
248
271
  .split(/[^a-z0-9]+/)
249
272
  .map((token) => token.trim())
250
- // Keep longer tokens so fallback stays focused on higher-signal terms
251
- // and avoids broad one- and two-letter matches that overwhelm curation.
252
- .filter((token) => token.length >= MIN_CURATE_FALLBACK_TOKEN_LENGTH && !CURATE_FALLBACK_FILTER_WORDS.has(token)))).slice(0, MAX_CURATE_FALLBACK_KEYWORDS);
273
+ .filter((token) => token.length > 0 &&
274
+ !CURATE_FALLBACK_FILTER_WORDS.has(token) &&
275
+ (token.length >= MIN_CURATE_FALLBACK_TOKEN_LENGTH || CURATE_SHORT_FALLBACK_TOKENS.has(token))))).slice(0, MAX_CURATE_FALLBACK_KEYWORDS);
276
+ if (tokens.length === 1 && tokens[0] === normalizedWhole)
277
+ return [];
278
+ return tokens;
253
279
  }
254
280
  export function mergeCurateSearchResponses(base, extras) {
255
- const hitsByRef = new Map();
281
+ // The base (full-query) ranking is the relevance signal — keyword fallback
282
+ // searches exist only to ADD recall when that ranking is thin, never to
283
+ // re-rank it. So we PRESERVE base order and APPEND fallback-only hits below
284
+ // it. A single-token fallback match on an exact title/path normalizes to a
285
+ // high FTS score, but those scores are not comparable to the full-query
286
+ // (hybrid) scores; re-sorting the union by raw score (the prior behaviour)
287
+ // let that keyword junk leapfrog the contextually-relevant full-query hits.
288
+ // Dup refs (present in both base and a fallback) keep their base POSITION but
289
+ // take the MAX score, since matching both the full query and a key term is a
290
+ // stronger relevance signal for the downstream score floor.
291
+ const bestExtraStashScore = new Map();
292
+ for (const result of extras) {
293
+ for (const hit of result.hits.filter((entry) => entry.type !== "registry")) {
294
+ const prev = bestExtraStashScore.get(hit.ref);
295
+ if (prev === undefined || (hit.score ?? 0) > prev)
296
+ bestExtraStashScore.set(hit.ref, hit.score ?? 0);
297
+ }
298
+ }
299
+ const baseRefs = new Set();
300
+ const baseStash = [];
256
301
  for (const hit of base.hits.filter((entry) => entry.type !== "registry")) {
257
- hitsByRef.set(hit.ref, hit);
302
+ baseRefs.add(hit.ref);
303
+ const extraScore = bestExtraStashScore.get(hit.ref);
304
+ baseStash.push(extraScore !== undefined && extraScore > (hit.score ?? 0) ? { ...hit, score: extraScore } : hit);
258
305
  }
306
+ const extraOnly = new Map();
259
307
  for (const result of extras) {
260
308
  for (const hit of result.hits.filter((entry) => entry.type !== "registry")) {
261
- const existing = hitsByRef.get(hit.ref);
262
- if (!existing || (hit.score ?? 0) > (existing.score ?? 0)) {
263
- hitsByRef.set(hit.ref, hit);
264
- }
309
+ if (baseRefs.has(hit.ref))
310
+ continue;
311
+ const existing = extraOnly.get(hit.ref);
312
+ if (!existing || (hit.score ?? 0) > (existing.score ?? 0))
313
+ extraOnly.set(hit.ref, hit);
314
+ }
315
+ }
316
+ // Fallback-only hits must rank BELOW every full-query hit through the rest of
317
+ // the pipeline. The downstream selector (`selectCuratedStashHits`) RE-SORTS by
318
+ // score and derives its relevance floor from the top score, so preserving
319
+ // order here is not enough — a single-token FTS match (normalized ~0.9) would
320
+ // otherwise become the leader and evict the contextual full-query memories.
321
+ // We therefore restamp fallback-only scores into a band strictly below the
322
+ // minimum base score (keeping their own relative order). When there are no
323
+ // base hits, fallback IS the result, so scores are kept as-is.
324
+ const sortedExtra = [...extraOnly.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
325
+ const minBaseScore = baseStash.length
326
+ ? Math.min(...baseStash.map((hit) => hit.score ?? 0))
327
+ : Number.POSITIVE_INFINITY;
328
+ const cappedExtra = baseStash.length
329
+ ? sortedExtra.map((hit, i) => ({ ...hit, score: minBaseScore - 1e-6 * (i + 1) }))
330
+ : sortedExtra;
331
+ const mergedHits = [...baseStash, ...cappedExtra];
332
+ // Registry hits are supplemental fill — same rule: base first (max score on
333
+ // dups), then fallback-only registry hits appended by score.
334
+ const bestExtraRegScore = new Map();
335
+ for (const result of extras) {
336
+ for (const hit of result.registryHits ?? []) {
337
+ const prev = bestExtraRegScore.get(hit.id);
338
+ if (prev === undefined || (hit.score ?? 0) > prev)
339
+ bestExtraRegScore.set(hit.id, hit.score ?? 0);
265
340
  }
266
341
  }
267
- const registryById = new Map();
342
+ const baseRegIds = new Set();
343
+ const baseReg = [];
268
344
  for (const hit of base.registryHits ?? []) {
269
- registryById.set(hit.id, hit);
345
+ baseRegIds.add(hit.id);
346
+ const extraScore = bestExtraRegScore.get(hit.id);
347
+ baseReg.push(extraScore !== undefined && extraScore > (hit.score ?? 0) ? { ...hit, score: extraScore } : hit);
270
348
  }
349
+ const extraRegOnly = new Map();
271
350
  for (const result of extras) {
272
351
  for (const hit of result.registryHits ?? []) {
273
- const existing = registryById.get(hit.id);
274
- if (!existing || (hit.score ?? 0) > (existing.score ?? 0)) {
275
- registryById.set(hit.id, hit);
276
- }
352
+ if (baseRegIds.has(hit.id))
353
+ continue;
354
+ const existing = extraRegOnly.get(hit.id);
355
+ if (!existing || (hit.score ?? 0) > (existing.score ?? 0))
356
+ extraRegOnly.set(hit.id, hit);
277
357
  }
278
358
  }
279
359
  const warnings = Array.from(new Set([...(base.warnings ?? []), ...extras.flatMap((result) => result.warnings ?? [])]));
280
- const mergedHits = [...hitsByRef.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
281
- const mergedRegistryHits = [...registryById.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
360
+ const mergedRegistryHits = [
361
+ ...baseReg,
362
+ ...[...extraRegOnly.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)),
363
+ ];
282
364
  return {
283
365
  ...base,
284
366
  hits: mergedHits,
@@ -289,10 +371,10 @@ export function mergeCurateSearchResponses(base, extras) {
289
371
  }
290
372
  export async function searchForCuration(input) {
291
373
  const initial = await akmSearch({ ...input, skipLogging: true });
292
- if (hasSearchResults(initial))
374
+ if (!shouldRunCurateFallback(initial, input.limit))
293
375
  return initial;
294
376
  const fallbackQueries = deriveCurateFallbackQueries(input.query);
295
- if (fallbackQueries.length <= 1)
377
+ if (fallbackQueries.length === 0)
296
378
  return initial;
297
379
  const fallbackResults = await Promise.all(fallbackQueries.map((token) => akmSearch({
298
380
  query: token,
@@ -303,3 +385,241 @@ export async function searchForCuration(input) {
303
385
  })));
304
386
  return mergeCurateSearchResponses(initial, fallbackResults);
305
387
  }
388
+ function parseCurateIntent(query) {
389
+ const lower = query.toLowerCase();
390
+ return {
391
+ executionHeavy: /(run|script|bash|shell|cli|execute|automation|deploy|build|test|lint)/.test(lower),
392
+ multiStep: /(plan|workflow|steps?|procedure|rollout|review|migration|release|checklist)/.test(lower),
393
+ delegation: /(agent|assistant|planner|reviewer|architect|prompt)/.test(lower),
394
+ recall: /(memory|context|recall|remember)/.test(lower),
395
+ reference: CURATE_REFERENCE_QUERY_RE.test(lower),
396
+ };
397
+ }
398
+ function computeCurateTypeNudge(type, intent) {
399
+ let nudge = 0;
400
+ if (intent.executionHeavy) {
401
+ if (type === "script")
402
+ nudge += 0.06;
403
+ else if (type === "command")
404
+ nudge += 0.04;
405
+ else if (type === "memory")
406
+ nudge -= 0.04;
407
+ }
408
+ if (intent.multiStep) {
409
+ if (type === "workflow")
410
+ nudge += 0.06;
411
+ else if (type === "skill")
412
+ nudge += 0.04;
413
+ else if (type === "knowledge")
414
+ nudge -= 0.02;
415
+ }
416
+ if (intent.delegation && type === "agent")
417
+ nudge += 0.06;
418
+ if (intent.recall && type === "memory")
419
+ nudge += 0.08;
420
+ if (intent.reference) {
421
+ if (type === "knowledge")
422
+ nudge += 0.05;
423
+ else if (type === "skill")
424
+ nudge += 0.02;
425
+ }
426
+ return nudge;
427
+ }
428
+ function getCurateFamily(ref) {
429
+ try {
430
+ const parsed = parseAssetRef(ref);
431
+ if (parsed.type === "skill") {
432
+ return { key: parsed.name, role: "root" };
433
+ }
434
+ if (parsed.type !== "knowledge")
435
+ return undefined;
436
+ const match = /^skills\/(.+?)\/references\/(.+)$/.exec(parsed.name);
437
+ if (!match)
438
+ return undefined;
439
+ return {
440
+ key: match[1],
441
+ role: "reference",
442
+ topicTokens: match[2]
443
+ .split(/[^a-z0-9]+/i)
444
+ .map((token) => token.trim().toLowerCase())
445
+ .filter(Boolean),
446
+ };
447
+ }
448
+ catch {
449
+ return undefined;
450
+ }
451
+ }
452
+ function annotateCurateHit(query, hit, index, intent) {
453
+ const rawScore = hit.score ?? 0;
454
+ const family = getCurateFamily(hit.ref);
455
+ let adjustedScore = rawScore + computeCurateTypeNudge(hit.type, intent);
456
+ if (family?.role === "root" && !isNarrowReferenceFamilyQuery(query, family))
457
+ adjustedScore += 0.07;
458
+ if (family?.role === "reference" && isNarrowReferenceFamilyQuery(query, family))
459
+ adjustedScore += 0.07;
460
+ return {
461
+ hit,
462
+ rawScore,
463
+ adjustedScore,
464
+ originalIndex: index,
465
+ family,
466
+ };
467
+ }
468
+ function compareCurateHits(a, b) {
469
+ const rawDiff = b.rawScore - a.rawScore;
470
+ if (Math.abs(rawDiff) > CURATE_CLOSE_SCORE_BAND)
471
+ return rawDiff;
472
+ const adjustedDiff = b.adjustedScore - a.adjustedScore;
473
+ if (adjustedDiff !== 0)
474
+ return adjustedDiff;
475
+ if (rawDiff !== 0)
476
+ return rawDiff;
477
+ return a.originalIndex - b.originalIndex;
478
+ }
479
+ function passesCurateScoreFloor(hit, leaderScore) {
480
+ if (leaderScore === undefined)
481
+ return true;
482
+ return hit.rawScore >= Math.max(CURATE_TAIL_SCORE_FLOOR, leaderScore * CURATE_RELATIVE_SCORE_FLOOR);
483
+ }
484
+ function isNarrowReferenceFamilyQuery(query, family) {
485
+ if (!family || family.role !== "reference")
486
+ return false;
487
+ const lower = query.toLowerCase();
488
+ if (CURATE_REFERENCE_QUERY_RE.test(lower))
489
+ return true;
490
+ return family.topicTokens.some((token) => token.length >= 3 && lower.includes(token));
491
+ }
492
+ function appendCurateSupportRef(supportRefsByRef, ownerRef, supportRef) {
493
+ const existing = supportRefsByRef.get(ownerRef) ?? [];
494
+ if (existing.some((entry) => entry.ref === supportRef.ref))
495
+ return;
496
+ supportRefsByRef.set(ownerRef, [...existing, supportRef]);
497
+ }
498
+ function selectCuratedStashHits(query, hits, limit) {
499
+ const intent = parseCurateIntent(query);
500
+ const collapsed = collapseCurateFamilies(query, hits);
501
+ const ranked = collapsed.hits
502
+ .map(({ hit, originalIndex }) => annotateCurateHit(query, hit, originalIndex, intent))
503
+ .sort(compareCurateHits);
504
+ const selected = [];
505
+ const supportRefsByRef = collapsed.supportRefsByRef;
506
+ let leaderScore;
507
+ for (const candidate of ranked) {
508
+ if (!passesCurateScoreFloor(candidate, leaderScore))
509
+ continue;
510
+ selected.push(candidate);
511
+ if (leaderScore === undefined)
512
+ leaderScore = candidate.rawScore;
513
+ if (selected.length >= limit)
514
+ break;
515
+ }
516
+ return { selected: selected.map((entry) => entry.hit), supportRefsByRef };
517
+ }
518
+ function collapseCurateFamilies(query, hits) {
519
+ const passthrough = [];
520
+ const supportRefsByRef = new Map();
521
+ const groups = new Map();
522
+ for (const [index, hit] of hits.entries()) {
523
+ const family = getCurateFamily(hit.ref);
524
+ if (!family) {
525
+ passthrough.push({ hit, originalIndex: index });
526
+ continue;
527
+ }
528
+ const group = groups.get(family.key) ?? { references: [] };
529
+ if (family.role === "root") {
530
+ if (!group.root)
531
+ group.root = { hit, originalIndex: index };
532
+ }
533
+ else {
534
+ group.references.push({ hit, originalIndex: index });
535
+ }
536
+ groups.set(family.key, group);
537
+ }
538
+ const collapsedFamilies = [];
539
+ for (const group of groups.values()) {
540
+ const bestReference = group.references[0];
541
+ const representative = group.root && !isNarrowReferenceFamilyQuery(query, getCurateFamily(bestReference?.hit.ref ?? group.root.hit.ref))
542
+ ? group.root
543
+ : (bestReference ?? group.root);
544
+ if (!representative)
545
+ continue;
546
+ collapsedFamilies.push(representative);
547
+ const supportCandidates = [group.root, ...group.references].filter((entry) => {
548
+ return entry !== undefined && entry.hit.ref !== representative.hit.ref;
549
+ });
550
+ for (const support of supportCandidates) {
551
+ appendCurateSupportRef(supportRefsByRef, representative.hit.ref, {
552
+ ref: support.hit.ref,
553
+ type: support.hit.type,
554
+ reason: "Related family asset to inspect next.",
555
+ });
556
+ }
557
+ }
558
+ return {
559
+ hits: [...passthrough, ...collapsedFamilies].sort((a, b) => a.originalIndex - b.originalIndex),
560
+ supportRefsByRef,
561
+ };
562
+ }
563
+ function preferBroadRootRepresentative(query, selected, allHits, supportRefsByRef) {
564
+ const first = selected[0];
565
+ if (!first)
566
+ return { selected, supportRefsByRef };
567
+ const match = /^knowledge:skills\/(.+?)\/references\/(.+)$/.exec(first.ref);
568
+ if (!match)
569
+ return { selected, supportRefsByRef };
570
+ const lower = query.toLowerCase();
571
+ const topicTokens = match[2].split(/[^a-z0-9]+/i).filter(Boolean);
572
+ const wantsReference = CURATE_REFERENCE_QUERY_RE.test(lower) ||
573
+ topicTokens.some((token) => token.length >= 3 && lower.includes(token.toLowerCase()));
574
+ if (wantsReference)
575
+ return { selected, supportRefsByRef };
576
+ const rootRef = `skill:${match[1]}`;
577
+ const rootHit = allHits.find((hit) => hit.ref === rootRef);
578
+ if (!rootHit)
579
+ return { selected, supportRefsByRef };
580
+ const next = [rootHit, ...selected.filter((hit) => hit.ref !== first.ref && hit.ref !== rootRef)];
581
+ const merged = new Map(supportRefsByRef);
582
+ const priorSupport = merged.get(first.ref) ?? [];
583
+ for (const entry of priorSupport)
584
+ appendCurateSupportRef(merged, rootRef, entry);
585
+ appendCurateSupportRef(merged, rootRef, {
586
+ ref: first.ref,
587
+ type: first.type,
588
+ reason: "Related family asset to inspect next.",
589
+ });
590
+ merged.delete(first.ref);
591
+ return { selected: next, supportRefsByRef: merged };
592
+ }
593
+ function mergeCurateSupportRefs(seeded, relatedHits, selectedRefs, ownerRef) {
594
+ const merged = [];
595
+ for (const entry of seeded) {
596
+ if (entry.ref === ownerRef || selectedRefs.has(entry.ref))
597
+ continue;
598
+ if (merged.some((existing) => existing.ref === entry.ref))
599
+ continue;
600
+ merged.push(entry);
601
+ if (merged.length >= MAX_CURATE_SUPPORT_REFS)
602
+ return merged;
603
+ }
604
+ if (!Array.isArray(relatedHits))
605
+ return merged;
606
+ for (const hit of relatedHits) {
607
+ if (!hit.ref || hit.ref === ownerRef || selectedRefs.has(hit.ref))
608
+ continue;
609
+ if (merged.some((existing) => existing.ref === hit.ref))
610
+ continue;
611
+ merged.push({ ref: hit.ref, type: hit.type, reason: "Related asset via shared entities." });
612
+ if (merged.length >= MAX_CURATE_SUPPORT_REFS)
613
+ break;
614
+ }
615
+ return merged;
616
+ }
617
+ function shouldRunCurateFallback(initial, desiredCount) {
618
+ const stashHits = initial.hits.filter((hit) => hit.type !== "registry");
619
+ if (stashHits.length === 0)
620
+ return true;
621
+ const topScore = stashHits[0]?.score ?? 0;
622
+ const strongFloor = Math.max(CURATE_FALLBACK_STRONG_SCORE_FLOOR, topScore * CURATE_RELATIVE_SCORE_FLOOR);
623
+ const strongCount = stashHits.filter((hit) => (hit.score ?? 0) >= strongFloor).length;
624
+ return !(topScore >= CURATE_FALLBACK_TOP_SCORE_THRESHOLD && strongCount >= Math.min(2, desiredCount));
625
+ }
@@ -16,7 +16,7 @@ import { isHttpUrl, isWithin, tryReadStdinText } from "../../core/common.js";
16
16
  import { loadConfig } from "../../core/config/config.js";
17
17
  import { UsageError } from "../../core/errors.js";
18
18
  import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
19
- import { fetchWebsiteMarkdownSnapshot } from "../../sources/website-ingest.js";
19
+ import { fetchWebsiteMarkdownSnapshot, shouldAllowPrivateWebsiteUrlForTests } from "../../sources/website-ingest.js";
20
20
  const MAX_CAPTURED_ASSET_SLUG_LENGTH = 64;
21
21
  // ── Asset-name normalisation ─────────────────────────────────────────────────
22
22
  /**
@@ -101,10 +101,13 @@ export function readKnowledgeContent(source) {
101
101
  * URLs are fetched via `fetchWebsiteMarkdownSnapshot`; local sources delegate
102
102
  * to `readKnowledgeContent`.
103
103
  */
104
- export async function readKnowledgeInput(source) {
104
+ export async function readKnowledgeInput(source, options) {
105
105
  if (!isHttpUrl(source))
106
106
  return readKnowledgeContent(source);
107
- const snapshot = await fetchWebsiteMarkdownSnapshot(source);
107
+ const snapshot = await fetchWebsiteMarkdownSnapshot(source, {
108
+ stashDir: options?.stashDir,
109
+ allowPrivateHosts: options?.allowPrivateHosts ?? shouldAllowPrivateWebsiteUrlForTests(source),
110
+ });
108
111
  return { content: snapshot.content, preferredName: snapshot.preferredName };
109
112
  }
110
113
  // ── Asset writing ────────────────────────────────────────────────────────────