akm-cli 0.8.0-rc2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (313) hide show
  1. package/{.github/CHANGELOG.md → CHANGELOG.md} +238 -3
  2. package/README.md +22 -6
  3. package/SECURITY.md +93 -0
  4. package/dist/assets/help/help-accept.md +12 -0
  5. package/dist/assets/help/help-improve.md +81 -0
  6. package/dist/{commands → assets}/help/help-proposals.md +7 -4
  7. package/dist/assets/help/help-reject.md +11 -0
  8. package/dist/{output → assets/hints}/cli-hints-full.md +60 -32
  9. package/dist/{output → assets/hints}/cli-hints-short.md +10 -7
  10. package/dist/assets/profiles/default.json +15 -0
  11. package/dist/assets/profiles/graph-refresh.json +13 -0
  12. package/dist/assets/profiles/memory-focus.json +12 -0
  13. package/dist/assets/profiles/quick.json +15 -0
  14. package/dist/assets/profiles/thorough.json +15 -0
  15. package/dist/assets/prompts/extract-session.md +80 -0
  16. package/dist/assets/prompts/graph-extract-user-prompt.md +35 -0
  17. package/dist/assets/tasks/graph-refresh-weekly.yml +10 -0
  18. package/dist/cli/config-migrate.js +144 -0
  19. package/dist/cli/config-validate.js +39 -0
  20. package/dist/cli/confirm.js +73 -0
  21. package/dist/cli/parse-args.js +93 -3
  22. package/dist/cli/shared.js +129 -0
  23. package/dist/cli.js +2141 -1268
  24. package/dist/commands/add-cli.js +279 -0
  25. package/dist/commands/agent-dispatch.js +20 -12
  26. package/dist/commands/agent-support.js +11 -5
  27. package/dist/commands/completions.js +3 -0
  28. package/dist/commands/config-cli.js +129 -517
  29. package/dist/commands/consolidate.js +1557 -147
  30. package/dist/commands/curate.js +44 -3
  31. package/dist/commands/db-cli.js +23 -0
  32. package/dist/commands/distill-promotion-policy.js +5 -3
  33. package/dist/commands/distill.js +906 -100
  34. package/dist/commands/env.js +213 -0
  35. package/dist/commands/eval-cases.js +3 -0
  36. package/dist/commands/events.js +3 -0
  37. package/dist/commands/extract-cli.js +127 -0
  38. package/dist/commands/extract-prompt.js +217 -0
  39. package/dist/commands/extract.js +477 -0
  40. package/dist/commands/feedback-cli.js +331 -0
  41. package/dist/commands/graph.js +260 -5
  42. package/dist/commands/health.js +1042 -55
  43. package/dist/commands/history.js +51 -16
  44. package/dist/commands/improve-auto-accept.js +97 -0
  45. package/dist/commands/improve-cli.js +236 -0
  46. package/dist/commands/improve-profiles.js +138 -0
  47. package/dist/commands/improve-result-file.js +167 -0
  48. package/dist/commands/improve.js +1736 -346
  49. package/dist/commands/info.js +26 -28
  50. package/dist/commands/init.js +49 -1
  51. package/dist/commands/installed-stashes.js +6 -23
  52. package/dist/commands/knowledge.js +3 -0
  53. package/dist/commands/lint/agent-linter.js +3 -0
  54. package/dist/commands/lint/base-linter.js +199 -5
  55. package/dist/commands/lint/command-linter.js +3 -0
  56. package/dist/commands/lint/default-linter.js +3 -0
  57. package/dist/commands/lint/env-key-rules.js +154 -0
  58. package/dist/commands/lint/index.js +92 -3
  59. package/dist/commands/lint/knowledge-linter.js +3 -0
  60. package/dist/commands/lint/markdown-insertion.js +343 -0
  61. package/dist/commands/lint/memory-linter.js +3 -0
  62. package/dist/commands/lint/registry.js +3 -0
  63. package/dist/commands/lint/skill-linter.js +3 -0
  64. package/dist/commands/lint/task-linter.js +15 -12
  65. package/dist/commands/lint/types.js +3 -0
  66. package/dist/commands/lint/workflow-linter.js +3 -0
  67. package/dist/commands/lint.js +3 -0
  68. package/dist/commands/migration-help.js +5 -2
  69. package/dist/commands/proposal-drain-policies.js +128 -0
  70. package/dist/commands/proposal-drain.js +477 -0
  71. package/dist/commands/proposal.js +60 -6
  72. package/dist/commands/propose.js +24 -19
  73. package/dist/commands/reflect.js +1004 -94
  74. package/dist/commands/registry-cli.js +150 -0
  75. package/dist/commands/registry-search.js +3 -0
  76. package/dist/commands/remember-cli.js +257 -0
  77. package/dist/commands/remember.js +15 -6
  78. package/dist/commands/schema-repair.js +88 -15
  79. package/dist/commands/search.js +99 -14
  80. package/dist/commands/secret.js +173 -0
  81. package/dist/commands/self-update.js +3 -0
  82. package/dist/commands/show.js +32 -13
  83. package/dist/commands/source-add.js +7 -35
  84. package/dist/commands/source-clone.js +3 -0
  85. package/dist/commands/source-manage.js +3 -0
  86. package/dist/commands/tasks.js +161 -95
  87. package/dist/commands/url-checker.js +3 -0
  88. package/dist/core/action-contributors.js +3 -0
  89. package/dist/core/asset-ref.js +13 -2
  90. package/dist/core/asset-registry.js +9 -2
  91. package/dist/core/asset-serialize.js +88 -0
  92. package/dist/core/asset-spec.js +61 -5
  93. package/dist/core/common.js +93 -5
  94. package/dist/core/concurrent.js +3 -0
  95. package/dist/core/config-io.js +347 -0
  96. package/dist/core/config-migration.js +622 -0
  97. package/dist/core/config-schema.js +558 -0
  98. package/dist/core/config-sources.js +108 -0
  99. package/dist/core/config-types.js +4 -0
  100. package/dist/core/config-walker.js +337 -0
  101. package/dist/core/config.js +366 -1077
  102. package/dist/core/errors.js +42 -20
  103. package/dist/core/events.js +31 -25
  104. package/dist/core/file-lock.js +104 -0
  105. package/dist/core/frontmatter.js +75 -10
  106. package/dist/core/lesson-lint.js +3 -0
  107. package/dist/core/markdown.js +3 -0
  108. package/dist/core/memory-belief.js +62 -0
  109. package/dist/core/memory-contradiction-detect.js +274 -0
  110. package/dist/core/memory-improve.js +142 -14
  111. package/dist/core/parse.js +3 -0
  112. package/dist/core/paths.js +218 -50
  113. package/dist/core/proposal-quality-validators.js +380 -0
  114. package/dist/core/proposal-validators.js +11 -3
  115. package/dist/core/proposals.js +464 -5
  116. package/dist/core/state-db.js +349 -56
  117. package/dist/core/text-truncation.js +107 -0
  118. package/dist/core/time.js +3 -0
  119. package/dist/core/tty.js +59 -0
  120. package/dist/core/warn.js +7 -2
  121. package/dist/core/write-source.js +12 -0
  122. package/dist/indexer/db-backup.js +391 -0
  123. package/dist/indexer/db-search.js +136 -28
  124. package/dist/indexer/db.js +661 -166
  125. package/dist/indexer/ensure-index.js +3 -0
  126. package/dist/indexer/file-context.js +3 -0
  127. package/dist/indexer/graph-boost.js +162 -40
  128. package/dist/indexer/graph-db.js +241 -51
  129. package/dist/indexer/graph-dedup.js +3 -7
  130. package/dist/indexer/graph-extraction.js +242 -149
  131. package/dist/indexer/index-context.js +3 -9
  132. package/dist/indexer/indexer.js +86 -16
  133. package/dist/indexer/llm-cache.js +24 -19
  134. package/dist/indexer/manifest.js +3 -0
  135. package/dist/indexer/matchers.js +184 -11
  136. package/dist/indexer/memory-inference.js +94 -50
  137. package/dist/indexer/metadata-contributors.js +3 -0
  138. package/dist/indexer/metadata.js +110 -50
  139. package/dist/indexer/path-resolver.js +3 -0
  140. package/dist/indexer/project-context.js +192 -0
  141. package/dist/indexer/ranking-contributors.js +134 -7
  142. package/dist/indexer/ranking.js +8 -1
  143. package/dist/indexer/search-fields.js +5 -9
  144. package/dist/indexer/search-hit-enrichers.js +91 -2
  145. package/dist/indexer/search-source.js +20 -1
  146. package/dist/indexer/semantic-status.js +4 -1
  147. package/dist/indexer/staleness-detect.js +447 -0
  148. package/dist/indexer/usage-events.js +12 -9
  149. package/dist/indexer/walker.js +3 -0
  150. package/dist/integrations/agent/builders.js +135 -0
  151. package/dist/integrations/agent/config.js +121 -401
  152. package/dist/integrations/agent/detect.js +3 -0
  153. package/dist/integrations/agent/index.js +6 -14
  154. package/dist/integrations/agent/model-aliases.js +55 -0
  155. package/dist/integrations/agent/profiles.js +3 -0
  156. package/dist/integrations/agent/prompts.js +137 -8
  157. package/dist/integrations/agent/runner.js +208 -0
  158. package/dist/integrations/agent/sdk-runner.js +8 -2
  159. package/dist/integrations/agent/spawn.js +54 -14
  160. package/dist/integrations/github.js +3 -0
  161. package/dist/integrations/lockfile.js +22 -51
  162. package/dist/integrations/session-logs/index.js +4 -0
  163. package/dist/integrations/session-logs/inline-refs.js +35 -0
  164. package/dist/integrations/session-logs/pre-filter.js +152 -0
  165. package/dist/integrations/session-logs/providers/claude-code.js +226 -0
  166. package/dist/integrations/session-logs/providers/opencode.js +231 -25
  167. package/dist/integrations/session-logs/types.js +3 -0
  168. package/dist/llm/call-ai.js +14 -26
  169. package/dist/llm/client.js +16 -2
  170. package/dist/llm/embedder.js +20 -29
  171. package/dist/llm/embedders/cache.js +3 -7
  172. package/dist/llm/embedders/local.js +42 -1
  173. package/dist/llm/embedders/remote.js +20 -8
  174. package/dist/llm/embedders/types.js +3 -7
  175. package/dist/llm/feature-gate.js +92 -56
  176. package/dist/llm/graph-extract.js +402 -31
  177. package/dist/llm/index-passes.js +44 -29
  178. package/dist/llm/memory-infer.js +30 -2
  179. package/dist/llm/metadata-enhance.js +3 -7
  180. package/dist/output/cli-hints.js +7 -4
  181. package/dist/output/context.js +60 -8
  182. package/dist/output/renderers.js +170 -194
  183. package/dist/output/shapes/curate.js +56 -0
  184. package/dist/output/shapes/distill.js +10 -0
  185. package/dist/output/shapes/env-list.js +19 -0
  186. package/dist/output/shapes/events.js +11 -0
  187. package/dist/output/shapes/helpers.js +424 -0
  188. package/dist/output/shapes/history.js +7 -0
  189. package/dist/output/shapes/passthrough.js +105 -0
  190. package/dist/output/shapes/proposal-accept.js +7 -0
  191. package/dist/output/shapes/proposal-diff.js +7 -0
  192. package/dist/output/shapes/proposal-list.js +7 -0
  193. package/dist/output/shapes/proposal-producer.js +11 -0
  194. package/dist/output/shapes/proposal-reject.js +7 -0
  195. package/dist/output/shapes/proposal-show.js +7 -0
  196. package/dist/output/shapes/registry-search.js +6 -0
  197. package/dist/output/shapes/registry.js +30 -0
  198. package/dist/output/shapes/search.js +6 -0
  199. package/dist/output/shapes/secret-list.js +19 -0
  200. package/dist/output/shapes/show.js +6 -0
  201. package/dist/output/shapes/vault-list.js +19 -0
  202. package/dist/output/shapes.js +51 -549
  203. package/dist/output/text/add.js +6 -0
  204. package/dist/output/text/clone.js +6 -0
  205. package/dist/output/text/config.js +6 -0
  206. package/dist/output/text/curate.js +6 -0
  207. package/dist/output/text/distill.js +7 -0
  208. package/dist/output/text/enable-disable.js +7 -0
  209. package/dist/output/text/events.js +10 -0
  210. package/dist/output/text/feedback.js +6 -0
  211. package/dist/output/text/helpers.js +1059 -0
  212. package/dist/output/text/history.js +7 -0
  213. package/dist/output/text/import.js +6 -0
  214. package/dist/output/text/index.js +6 -0
  215. package/dist/output/text/info.js +6 -0
  216. package/dist/output/text/init.js +6 -0
  217. package/dist/output/text/list.js +6 -0
  218. package/dist/output/text/proposal-producer.js +8 -0
  219. package/dist/output/text/proposal.js +12 -0
  220. package/dist/output/text/registry-commands.js +11 -0
  221. package/dist/output/text/registry.js +30 -0
  222. package/dist/output/text/remember.js +6 -0
  223. package/dist/output/text/remove.js +6 -0
  224. package/dist/output/text/save.js +6 -0
  225. package/dist/output/text/search.js +6 -0
  226. package/dist/output/text/show.js +6 -0
  227. package/dist/output/text/update.js +6 -0
  228. package/dist/output/text/upgrade.js +6 -0
  229. package/dist/output/text/vault.js +16 -0
  230. package/dist/output/text/wiki.js +15 -0
  231. package/dist/output/text/workflow.js +14 -0
  232. package/dist/output/text.js +44 -1329
  233. package/dist/registry/build-index.js +3 -0
  234. package/dist/registry/create-provider-registry.js +3 -0
  235. package/dist/registry/factory.js +4 -1
  236. package/dist/registry/origin-resolve.js +3 -0
  237. package/dist/registry/providers/index.js +3 -0
  238. package/dist/registry/providers/skills-sh.js +11 -2
  239. package/dist/registry/providers/static-index.js +10 -1
  240. package/dist/registry/providers/types.js +3 -24
  241. package/dist/registry/resolve.js +11 -16
  242. package/dist/registry/types.js +3 -0
  243. package/dist/scripts/migrate-storage.js +17767 -0
  244. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
  245. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  246. package/dist/setup/detect.js +3 -0
  247. package/dist/setup/ripgrep-install.js +3 -0
  248. package/dist/setup/ripgrep-resolve.js +3 -0
  249. package/dist/setup/setup.js +306 -67
  250. package/dist/setup/steps.js +3 -15
  251. package/dist/sources/include.js +3 -0
  252. package/dist/sources/provider-factory.js +3 -11
  253. package/dist/sources/provider.js +3 -20
  254. package/dist/sources/providers/filesystem.js +19 -23
  255. package/dist/sources/providers/git.js +171 -21
  256. package/dist/sources/providers/index.js +3 -0
  257. package/dist/sources/providers/install-types.js +3 -13
  258. package/dist/sources/providers/npm.js +3 -4
  259. package/dist/sources/providers/provider-utils.js +3 -0
  260. package/dist/sources/providers/sync-from-ref.js +3 -11
  261. package/dist/sources/providers/tar-utils.js +3 -0
  262. package/dist/sources/providers/website.js +18 -22
  263. package/dist/sources/resolve.js +3 -0
  264. package/dist/sources/types.js +3 -0
  265. package/dist/sources/website-ingest.js +3 -0
  266. package/dist/tasks/backends/cron.js +3 -0
  267. package/dist/tasks/backends/exec-utils.js +3 -0
  268. package/dist/tasks/backends/index.js +3 -11
  269. package/dist/tasks/backends/launchd.js +4 -1
  270. package/dist/tasks/backends/schtasks.js +4 -1
  271. package/dist/tasks/parser.js +51 -38
  272. package/dist/tasks/resolveAkmBin.js +3 -0
  273. package/dist/tasks/runner.js +35 -9
  274. package/dist/tasks/schedule.js +20 -1
  275. package/dist/tasks/schema.js +5 -3
  276. package/dist/tasks/validator.js +6 -3
  277. package/dist/version.js +3 -0
  278. package/dist/wiki/wiki-templates.js +6 -3
  279. package/dist/wiki/wiki.js +4 -1
  280. package/dist/workflows/authoring.js +4 -1
  281. package/dist/workflows/cli.js +3 -0
  282. package/dist/workflows/db.js +140 -10
  283. package/dist/workflows/document-cache.js +3 -10
  284. package/dist/workflows/parser.js +3 -0
  285. package/dist/workflows/renderer.js +3 -0
  286. package/dist/workflows/runs.js +18 -1
  287. package/dist/workflows/schema.js +3 -0
  288. package/dist/workflows/scope-key.js +3 -0
  289. package/dist/workflows/validator.js +5 -9
  290. package/docs/README.md +7 -2
  291. package/docs/data-and-telemetry.md +225 -0
  292. package/docs/migration/release-notes/0.7.5.md +2 -2
  293. package/docs/migration/release-notes/0.8.0.md +57 -5
  294. package/docs/migration/v0.7-to-v0.8.md +1378 -0
  295. package/package.json +28 -11
  296. package/.github/LICENSE +0 -374
  297. package/dist/commands/help/help-accept.md +0 -9
  298. package/dist/commands/help/help-improve.md +0 -53
  299. package/dist/commands/help/help-reject.md +0 -8
  300. package/dist/commands/install-audit.js +0 -385
  301. package/dist/commands/vault.js +0 -310
  302. package/dist/indexer/match-contributors.js +0 -141
  303. package/dist/integrations/agent/pipeline.js +0 -39
  304. package/dist/integrations/agent/runners.js +0 -31
  305. package/dist/llm/prompts/graph-extract-user-prompt.md +0 -12
  306. /package/dist/{tasks → assets}/backends/launchd-template.xml +0 -0
  307. /package/dist/{tasks → assets}/backends/schtasks-template.xml +0 -0
  308. /package/dist/{commands → assets}/help/help-propose.md +0 -0
  309. /package/dist/{wiki → assets/wiki}/index-template.md +0 -0
  310. /package/dist/{wiki → assets/wiki}/ingest-workflow-template.md +0 -0
  311. /package/dist/{wiki → assets/wiki}/log-template.md +0 -0
  312. /package/dist/{wiki → assets/wiki}/schema-template.md +0 -0
  313. /package/dist/{workflows → assets/workflows}/workflow-template.md +0 -0
@@ -1,11 +1,60 @@
1
- import { createHash } from "node:crypto";
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/.
2
4
  import fs from "node:fs";
3
5
  import path from "node:path";
4
- import { parseAgentConfig } from "../integrations/agent/config";
5
- import { asNonEmptyString, filterNonEmptyStrings, writeFileAtomic } from "./common";
6
+ import { backupExistingConfig, parseConfigText, withConfigLock, writeConfigAtomic } from "./config-io";
7
+ import { CURRENT_CONFIG_VERSION, compareConfigVersion, migrateConfigShape } from "./config-migration";
8
+ import { AkmConfigSchema } from "./config-schema";
6
9
  import { ConfigError } from "./errors";
10
+ export { stripJsonComments } from "./config-io";
7
11
  import { getCacheDir, getConfigPath } from "./paths";
8
12
  import { warn } from "./warn";
13
+ // ── Feedback failure-mode constants (F-3 / #384) ────────────────────────────
14
+ /**
15
+ * Curated taxonomy of failure modes for negative feedback.
16
+ *
17
+ * Structured failure modes enable aggregation across feedback events so the
18
+ * distill pipeline can detect that "5 assets failed for the same reason" and
19
+ * act on it — free-text strings about the same issue are not aggregatable.
20
+ */
21
+ export const FEEDBACK_FAILURE_MODES = [
22
+ "incorrect", // Factually wrong or logically flawed content
23
+ "outdated", // Correct at some point but now stale
24
+ "dangerous", // Could cause harm if followed (security, safety)
25
+ "incomplete", // Missing key steps, context, or caveats
26
+ "redundant", // Duplicates another asset without adding value
27
+ ];
28
+ /**
29
+ * Default value for {@link IndexPassConfig.graphExtractionBatchSize}. Chosen
30
+ * empirically: 4 amortises the per-call HTTP overhead 4× while keeping the
31
+ * combined prompt size well under common 8K/16K context windows (each body is
32
+ * sliced to ~500 chars in the graph-extract prompt builder).
33
+ */
34
+ export const DEFAULT_GRAPH_EXTRACTION_BATCH_SIZE = 4;
35
+ /**
36
+ * Approximate character budget per asset body inside a batched
37
+ * graph-extraction prompt — used by {@link resolveBatchSize} to derive a
38
+ * context-window ceiling when `llm.contextLength` is configured. This accounts
39
+ * for the actual `MAX_BODY_CHARS` (500) in graph-extract.ts plus the system
40
+ * prompt, user prompt wrapper, and expected JSON response overhead.
41
+ */
42
+ const GRAPH_EXTRACTION_CHARS_PER_BODY = 1500;
43
+ /**
44
+ * Clamp a configured batch size against the model's known context window.
45
+ *
46
+ * `configured` defaults to {@link DEFAULT_GRAPH_EXTRACTION_BATCH_SIZE} when
47
+ * `undefined`. When `contextLength` is provided, the result is the smaller of
48
+ * `configured` and `floor(contextLength / GRAPH_EXTRACTION_CHARS_PER_BODY)`,
49
+ * with a floor of 1 so the batched path always processes at least one body.
50
+ */
51
+ export function resolveBatchSize(configured, contextLength) {
52
+ const base = configured && configured > 0 ? configured : DEFAULT_GRAPH_EXTRACTION_BATCH_SIZE;
53
+ if (!contextLength || contextLength <= 0)
54
+ return base;
55
+ const ceiling = Math.max(1, Math.floor(contextLength / GRAPH_EXTRACTION_CHARS_PER_BODY));
56
+ return Math.max(1, Math.min(base, ceiling));
57
+ }
9
58
  // ── Defaults ────────────────────────────────────────────────────────────────
10
59
  export const DEFAULT_CONFIG = {
11
60
  semanticSearchMode: "auto",
@@ -18,65 +67,11 @@ export const DEFAULT_CONFIG = {
18
67
  detail: "brief",
19
68
  },
20
69
  };
21
- // ── Paths ───────────────────────────────────────────────────────────────────
22
- // ── Private helpers ─────────────────────────────────────────────────────────
23
- /**
24
- * Returns `value` if it is a finite positive integer; otherwise `undefined`.
25
- * Used to validate numeric config fields like `dimension`, `contextLength`,
26
- * `timeoutMs`, `maxTokens`, and `ollamaOptions.num_ctx`.
27
- */
28
- function parsePositiveInteger(_fieldPath, value) {
29
- if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) {
30
- return undefined;
31
- }
32
- return value;
33
- }
34
- function parseNonNegativeNumber(value) {
35
- if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
36
- return undefined;
37
- return value;
38
- }
39
- /**
40
- * Returns `value` if it is a string present in `allowed`; otherwise `undefined`.
41
- */
42
- function isOneOf(value, allowed) {
43
- return typeof value === "string" && allowed.includes(value);
44
- }
45
- /**
46
- * Validates that `url` starts with `http://` or `https://`. Returns `url` on
47
- * success and warns+returns `undefined` on failure. `fieldName` is used only
48
- * in the warning message.
49
- */
50
- function isValidHttpUrl(url, fieldName) {
51
- if (typeof url !== "string" || !url)
52
- return undefined;
53
- if (!url.startsWith("http://") && !url.startsWith("https://")) {
54
- warn(`[akm] Ignoring ${fieldName}: endpoint must start with http:// or https://, got "${url}"`);
55
- return undefined;
56
- }
57
- return url;
58
- }
59
- function clearAllCaches() {
60
- cachedConfig = undefined;
61
- cachedUserConfig = undefined;
62
- }
63
70
  // ── Load / Save / Update ────────────────────────────────────────────────────
64
71
  const PROJECT_CONFIG_RELATIVE_PATH = path.join(".akm", "config.json");
65
72
  let cachedConfig;
66
- let cachedUserConfig;
67
73
  export function resetConfigCache() {
68
- clearAllCaches();
69
- }
70
- function hashString(text) {
71
- // Simple, fast non-cryptographic hash (FNV-1a 32-bit) — sufficient to detect
72
- // content changes between config writes when filesystem mtime resolution is
73
- // too coarse to reflect rapid back-to-back writes (common in tests).
74
- let hash = 0x811c9dc5;
75
- for (let i = 0; i < text.length; i++) {
76
- hash ^= text.charCodeAt(i);
77
- hash = Math.imul(hash, 0x01000193);
78
- }
79
- return (hash >>> 0).toString(16);
74
+ cachedConfig = undefined;
80
75
  }
81
76
  export function loadUserConfig() {
82
77
  const configPath = getConfigPath();
@@ -85,1048 +80,362 @@ export function loadUserConfig() {
85
80
  stat = fs.statSync(configPath);
86
81
  }
87
82
  catch {
88
- cachedUserConfig = undefined;
83
+ cachedConfig = undefined;
89
84
  return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
90
85
  }
91
- // Cache key combines mtimeMs + size + content hash. mtimeMs alone is unreliable
92
- // when tests write multiple times within the filesystem mtime resolution
93
- // window (often 1ms+). Reading + hashing on cache miss is cheap and ensures
94
- // we never serve stale config.
86
+ // Cache key: mtimeMs + size. Tests that write rapidly back-to-back inside
87
+ // the mtime resolution window MUST call resetConfigCache() between writes
88
+ // every public test helper already does.
89
+ if (cachedConfig &&
90
+ cachedConfig.path === configPath &&
91
+ cachedConfig.mtime === stat.mtimeMs &&
92
+ cachedConfig.size === stat.size) {
93
+ return cachedConfig.config;
94
+ }
95
95
  let text;
96
96
  try {
97
97
  text = fs.readFileSync(configPath, "utf8");
98
98
  }
99
99
  catch {
100
- cachedUserConfig = undefined;
100
+ cachedConfig = undefined;
101
101
  return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
102
102
  }
103
- const contentHash = hashString(text);
104
- if (cachedUserConfig &&
105
- cachedUserConfig.path === configPath &&
106
- cachedUserConfig.mtime === stat.mtimeMs &&
107
- cachedUserConfig.size === stat.size &&
108
- cachedUserConfig.contentHash === contentHash) {
109
- return cachedUserConfig.config;
103
+ // Auto-migration: rewrite legacy shapes to disk on cache miss so the schema
104
+ // sees canonical input. AKM_NO_AUTO_MIGRATE=1 skips the disk rewrite (still
105
+ // applies in-memory).
106
+ text = maybeAutoMigrateConfigFile(configPath, text);
107
+ const finalConfig = applyRuntimeEnvApiKeys(parseAndValidate(text, configPath));
108
+ // Re-stat after potential write-back so the cache key reflects the new mtime.
109
+ let finalStat = stat;
110
+ try {
111
+ finalStat = fs.statSync(configPath);
112
+ }
113
+ catch {
114
+ // Stat failed — use original stat for cache; no harm done.
110
115
  }
111
- const config = mergeLoadedConfig(DEFAULT_CONFIG, readNormalizedConfigFromText(text));
112
- const finalConfig = applyRuntimeEnvApiKeys(config);
113
- cachedUserConfig = {
116
+ cachedConfig = {
114
117
  config: finalConfig,
115
118
  path: configPath,
116
- mtime: stat.mtimeMs,
117
- size: stat.size,
118
- contentHash,
119
+ mtime: finalStat.mtimeMs,
120
+ size: finalStat.size,
119
121
  };
120
122
  return finalConfig;
121
123
  }
124
+ /**
125
+ * Parse raw config text, run the legacy-shape migration
126
+ * ({@link migrateConfigShape}), then validate via Zod
127
+ * ({@link AkmConfigSchema}). Returns the merged-with-defaults AkmConfig.
128
+ *
129
+ * The migration handles all one-time 0.7→0.8 transforms (legacy keys,
130
+ * boolean→string coercions, openviking rename); the schema then validates
131
+ * the canonical shape and throws on anything it doesn't recognise.
132
+ */
133
+ function parseAndValidate(text, sourcePath) {
134
+ // Migration absorbs 0.7→0.8 input transforms (semanticSearchMode bool→string,
135
+ // stashes[] → sources[], openviking removal); the schema then sees a
136
+ // canonical shape. Migration is idempotent on already-migrated input.
137
+ const migrated = migrateConfigShape(parseConfigText(text, sourcePath)).result;
138
+ const parsed = AkmConfigSchema.safeParse(migrated);
139
+ if (!parsed.success) {
140
+ const lines = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
141
+ const where = sourcePath ? ` at ${sourcePath}` : "";
142
+ throw new ConfigError(`Invalid config${where}:\n${lines}`, "INVALID_CONFIG_FILE");
143
+ }
144
+ return mergeLoadedConfig(DEFAULT_CONFIG, parsed.data);
145
+ }
122
146
  export function getSources(config) {
123
147
  return config.sources ?? [];
124
148
  }
125
149
  export function getEffectiveRegistries(config) {
126
150
  return config.registries ?? DEFAULT_CONFIG.registries ?? [];
127
151
  }
128
- export function requireLlmConfig(config) {
129
- if (!config.llm)
130
- throw new ConfigError("LLM is not configured. Run `akm config set llm` to configure one.", "LLM_NOT_CONFIGURED");
131
- return config.llm;
132
- }
133
- export function loadConfig() {
134
- const configPaths = getEffectiveConfigPaths();
135
- const signature = getConfigSignature(configPaths);
136
- if (cachedConfig && cachedConfig.signature === signature) {
137
- return cachedConfig.config;
138
- }
139
- let config = loadUserConfig();
140
- const userConfigPath = getConfigPath();
141
- for (const configPath of configPaths) {
142
- if (configPath === userConfigPath)
143
- continue;
144
- config = mergeLoadedConfig(config, readNormalizedConfig(configPath));
145
- }
146
- const finalConfig = applyRuntimeEnvApiKeys(config);
147
- cachedConfig = { config: finalConfig, signature };
148
- return finalConfig;
149
- }
150
- export function saveConfig(config) {
151
- clearAllCaches();
152
- const configPath = getConfigPath();
153
- const dir = path.dirname(configPath);
154
- fs.mkdirSync(dir, { recursive: true });
155
- backupExistingConfig(configPath);
156
- const sanitized = sanitizeConfigForWrite(config);
157
- writeConfigObject(configPath, sanitized);
158
- }
159
- function backupExistingConfig(configPath) {
160
- if (!fs.existsSync(configPath))
161
- return;
162
- const backupDir = path.join(getCacheDir(), "config-backups");
163
- fs.mkdirSync(backupDir, { recursive: true });
164
- const timestamp = new Date().toISOString().replace(/[.:]/g, "-");
165
- const backupPath = path.join(backupDir, `config-${timestamp}.json`);
166
- fs.copyFileSync(configPath, backupPath);
167
- const latestPath = path.join(backupDir, "config.latest.json");
168
- fs.copyFileSync(configPath, latestPath);
169
- }
170
152
  /**
171
- * Strip apiKey fields before writing config to disk.
172
- * API keys should be provided via environment variables
173
- * AKM_EMBED_API_KEY and AKM_LLM_API_KEY.
153
+ * Resolve the name of the default LLM profile.
154
+ *
155
+ * Resolution order:
156
+ * 1. `defaults.llm` — explicit pointer set by the user.
157
+ * 2. A profile literally named `default` under `profiles.llm` — implicit
158
+ * fallback. The convention "name your default profile `default`" is
159
+ * what `akm setup` produces, so an unset `defaults.llm` next to a
160
+ * `profiles.llm.default` block is overwhelmingly a config-rewrite
161
+ * casualty (see [[project_akm_config_clobber_trap]]) rather than
162
+ * intent. Treating that shape as configured avoids the silent
163
+ * `getDefaultLlmConfig → undefined → pass-returns-zero` failure mode
164
+ * that produced 18h of no-op memory-inference runs on 2026-05-23.
165
+ *
166
+ * Returns `undefined` when neither path resolves to a profile name.
174
167
  */
175
- function sanitizeConfigForWrite(config) {
176
- const sanitized = { ...config };
177
- if (config.embedding) {
178
- const { apiKey, ...rest } = config.embedding;
179
- sanitized.embedding = rest;
180
- }
181
- if (config.llm) {
182
- const { apiKey, ...rest } = config.llm;
183
- sanitized.llm = rest;
184
- }
185
- // Drop empty keys to keep config clean
186
- return sanitized;
187
- }
188
- export function updateConfig(partial) {
189
- const current = loadUserConfig();
190
- const merged = mergeLoadedConfig(current, partial);
191
- saveConfig(merged);
192
- return merged;
168
+ function resolveDefaultLlmProfileName(config) {
169
+ const explicit = config.defaults?.llm;
170
+ if (explicit)
171
+ return explicit;
172
+ if (config.profiles?.llm?.default)
173
+ return "default";
174
+ return undefined;
193
175
  }
194
- // ── Helpers ─────────────────────────────────────────────────────────────────
195
176
  /**
196
- * Normalize a raw config object into a sparse config layer containing only
197
- * recognized keys that were valid in the source object. This function does not
198
- * merge with DEFAULT_CONFIG; callers are responsible for layering defaults and
199
- * combining multiple config sources so project config files only override what
200
- * they set.
177
+ * Resolve the default LLM connection from `profiles.llm[defaults.llm]`.
178
+ *
179
+ * Throws {@link ConfigError} when no default profile can be resolved (neither
180
+ * `defaults.llm` nor an implicit `profiles.llm.default`) or when the named
181
+ * profile does not exist under `profiles.llm`. Use this in code paths that
182
+ * must have an LLM configured (per-pass index calls, distill, consolidate,
183
+ * etc).
201
184
  */
202
- function parseConfigLayer(raw) {
203
- const config = {};
204
- if (typeof raw.stashDir === "string" && raw.stashDir.trim()) {
205
- config.stashDir = raw.stashDir.trim();
206
- }
207
- // Backward compatibility: coerce legacy boolean values to string
208
- if (typeof raw.semanticSearchMode === "boolean") {
209
- config.semanticSearchMode = raw.semanticSearchMode ? "auto" : "off";
210
- }
211
- else if (isOneOf(raw.semanticSearchMode, ["off", "auto"])) {
212
- config.semanticSearchMode = raw.semanticSearchMode;
213
- }
214
- const embedding = parseEmbeddingConfig(raw.embedding);
215
- if (embedding)
216
- config.embedding = embedding;
217
- const llm = parseLlmConfig(raw.llm);
218
- if (llm)
219
- config.llm = llm;
220
- const index = parseIndexConfig(raw.index);
221
- if (index)
222
- config.index = index;
223
- const installed = parseInstalledEntries(raw.installed);
224
- if (installed)
225
- config.installed = installed;
226
- const registries = parseRegistriesConfig(raw.registries);
227
- if (registries)
228
- config.registries = registries;
229
- if (isOneOf(raw.stashInheritance, ["replace", "merge"])) {
230
- config.stashInheritance = raw.stashInheritance;
231
- }
232
- if (Array.isArray(raw.stashes)) {
233
- throw new ConfigError("The legacy `stashes[]` config key is no longer supported. Rename it to `sources`.", "INVALID_CONFIG_FILE");
234
- }
235
- const sources = parseSourcesConfig(raw.sources);
236
- if (sources) {
237
- config.sources = sources;
238
- }
239
- const security = parseSecurityConfig(raw.security);
240
- if (security)
241
- config.security = security;
242
- const output = parseOutputConfig(raw.output);
243
- if (output)
244
- config.output = output;
245
- if (typeof raw.writable === "boolean") {
246
- config.writable = raw.writable;
247
- }
248
- if (typeof raw.defaultWriteTarget === "string" && raw.defaultWriteTarget.trim()) {
249
- config.defaultWriteTarget = raw.defaultWriteTarget.trim();
250
- }
251
- if ("agent" in raw) {
252
- const agent = parseAgentConfig(raw.agent);
253
- if (agent)
254
- config.agent = agent;
255
- }
256
- if (typeof raw.search === "object" && raw.search !== null && !Array.isArray(raw.search)) {
257
- const searchRaw = raw.search;
258
- const searchConfig = {};
259
- for (const key of Object.keys(searchRaw)) {
260
- if (key !== "minScore" && key !== "graphBoost") {
261
- warn(`[akm] Ignoring unknown search key "${key}".`);
262
- }
263
- }
264
- if (typeof searchRaw.minScore === "number" && Number.isFinite(searchRaw.minScore) && searchRaw.minScore >= 0) {
265
- searchConfig.minScore = searchRaw.minScore;
266
- }
267
- if (typeof searchRaw.graphBoost === "object" &&
268
- searchRaw.graphBoost !== null &&
269
- !Array.isArray(searchRaw.graphBoost)) {
270
- const graphBoostRaw = searchRaw.graphBoost;
271
- const graphBoostConfig = {};
272
- for (const key of Object.keys(graphBoostRaw)) {
273
- if (key !== "directBoostPerEntity" &&
274
- key !== "directBoostCap" &&
275
- key !== "hopBoostPerEntity" &&
276
- key !== "hopBoostCap" &&
277
- key !== "maxHops" &&
278
- key !== "confidenceMode" &&
279
- key !== "confidenceWeight") {
280
- warn(`[akm] Ignoring unknown search.graphBoost key "${key}".`);
281
- }
282
- }
283
- const directBoostPerEntity = parseNonNegativeNumber(graphBoostRaw.directBoostPerEntity);
284
- if (directBoostPerEntity !== undefined)
285
- graphBoostConfig.directBoostPerEntity = directBoostPerEntity;
286
- const directBoostCap = parseNonNegativeNumber(graphBoostRaw.directBoostCap);
287
- if (directBoostCap !== undefined)
288
- graphBoostConfig.directBoostCap = directBoostCap;
289
- const hopBoostPerEntity = parseNonNegativeNumber(graphBoostRaw.hopBoostPerEntity);
290
- if (hopBoostPerEntity !== undefined)
291
- graphBoostConfig.hopBoostPerEntity = hopBoostPerEntity;
292
- const hopBoostCap = parseNonNegativeNumber(graphBoostRaw.hopBoostCap);
293
- if (hopBoostCap !== undefined)
294
- graphBoostConfig.hopBoostCap = hopBoostCap;
295
- const maxHops = parsePositiveInteger("search.graphBoost.maxHops", graphBoostRaw.maxHops);
296
- if (maxHops !== undefined)
297
- graphBoostConfig.maxHops = Math.min(maxHops, 3);
298
- if (isOneOf(graphBoostRaw.confidenceMode, ["off", "blend", "multiply"])) {
299
- graphBoostConfig.confidenceMode = graphBoostRaw.confidenceMode;
300
- }
301
- const confidenceWeight = parseNonNegativeNumber(graphBoostRaw.confidenceWeight);
302
- if (confidenceWeight !== undefined)
303
- graphBoostConfig.confidenceWeight = Math.min(confidenceWeight, 1);
304
- if (Object.keys(graphBoostConfig).length > 0)
305
- searchConfig.graphBoost = graphBoostConfig;
306
- }
307
- if (Object.keys(searchConfig).length > 0)
308
- config.search = searchConfig;
309
- }
310
- if (typeof raw.feedback === "object" && raw.feedback !== null && !Array.isArray(raw.feedback)) {
311
- const feedbackRaw = raw.feedback;
312
- const feedbackConfig = {};
313
- if (typeof feedbackRaw.requireReason === "boolean") {
314
- feedbackConfig.requireReason = feedbackRaw.requireReason;
315
- }
316
- if (Object.keys(feedbackConfig).length > 0)
317
- config.feedback = feedbackConfig;
318
- }
319
- if (typeof raw.archiveRetentionDays === "number" &&
320
- Number.isFinite(raw.archiveRetentionDays) &&
321
- raw.archiveRetentionDays >= 0) {
322
- config.archiveRetentionDays = raw.archiveRetentionDays;
323
- }
324
- return config;
325
- }
326
- function parseConfigText(text) {
327
- const raw = parseConfigObjectFromText(text);
328
- if (!raw)
329
- return undefined;
330
- const expanded = expandEnvVars(raw);
331
- return parseConfigLayer(expanded);
332
- }
333
- function readNormalizedConfig(configPath) {
334
- let text;
335
- try {
336
- text = fs.readFileSync(configPath, "utf8");
337
- }
338
- catch {
339
- return undefined;
340
- }
341
- return parseConfigText(text);
342
- }
343
- function readNormalizedConfigFromText(text) {
344
- return parseConfigText(text);
345
- }
346
- function parseOutputConfig(value) {
347
- if (typeof value !== "object" || value === null || Array.isArray(value))
348
- return undefined;
349
- const obj = value;
350
- const output = {};
351
- if (isOneOf(obj.format, ["json", "yaml", "text"])) {
352
- output.format = obj.format;
185
+ export function requireLlmConfig(config) {
186
+ const defaultName = resolveDefaultLlmProfileName(config);
187
+ if (!defaultName) {
188
+ throw new ConfigError("LLM is not configured. Run `akm setup` or set `defaults.llm` to a profile defined in `profiles.llm`.", "LLM_NOT_CONFIGURED");
353
189
  }
354
- if (isOneOf(obj.detail, ["brief", "normal", "full"])) {
355
- output.detail = obj.detail;
190
+ const profile = config.profiles?.llm?.[defaultName];
191
+ if (!profile) {
192
+ throw new ConfigError(`LLM default profile "${defaultName}" not found in profiles.llm.`, "LLM_NOT_CONFIGURED", `Available profiles: ${Object.keys(config.profiles?.llm ?? {}).join(", ") || "none"}. Run \`akm setup\` to configure.`);
356
193
  }
357
- return Object.keys(output).length > 0 ? output : undefined;
194
+ return profile;
358
195
  }
359
196
  /**
360
- * Field names that hold URLs and must NOT have env var substitution applied.
361
- * Expanding ${VAR} inside a URL could leak secrets by redirecting requests to
362
- * an attacker-controlled server if the config file is world-readable.
197
+ * Like {@link requireLlmConfig} but returns `undefined` instead of throwing
198
+ * when no LLM is configured. Use in code paths where the LLM is optional.
363
199
  */
364
- const URL_FIELD_NAMES = new Set(["url", "endpoint", "artifactUrl"]);
200
+ export function getDefaultLlmConfig(config) {
201
+ const defaultName = resolveDefaultLlmProfileName(config);
202
+ if (!defaultName)
203
+ return undefined;
204
+ return config.profiles?.llm?.[defaultName];
205
+ }
365
206
  /**
366
- * Recursively expand `${VAR}` references in all string values.
367
- * Supports `${VAR}`, `${VAR:-default}`, and bare `$VAR` at the start of a value.
368
- * Non-string values pass through unchanged.
207
+ * Run `migrateConfigShape` on the raw text and — unless `AKM_NO_AUTO_MIGRATE=1`
208
+ * is set persist the migrated result. Returns the (possibly migrated) text
209
+ * for the caller to feed into `parseAndValidate`.
369
210
  *
370
- * URL-type fields (named `url`, `endpoint`, `artifactUrl`, or whose value starts
371
- * with `http://` / `https://`) are skipped to prevent secret injection into URLs.
211
+ * If the on-disk config is newer than this binary's known version, the bytes
212
+ * are left untouched (we won't silently strip fields on downgrade).
372
213
  */
373
- function expandEnvVars(value, fieldName) {
374
- if (typeof value === "string") {
375
- // Skip URL-type fields by name or by value prefix, unless they contain ${VAR} syntax
376
- if (!value.includes("${") &&
377
- ((fieldName !== undefined && URL_FIELD_NAMES.has(fieldName)) ||
378
- value.startsWith("http://") ||
379
- value.startsWith("https://"))) {
380
- return value;
381
- }
382
- return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
383
- if (braced) {
384
- const [name, ...rest] = braced.split(":-");
385
- const fallback = rest.join(":-");
386
- return process.env[name] ?? fallback ?? "";
387
- }
388
- return process.env[bare] ?? "";
389
- });
390
- }
391
- if (Array.isArray(value)) {
392
- return value.map((item) => expandEnvVars(item));
393
- }
394
- if (value !== null && typeof value === "object") {
395
- const out = {};
396
- for (const [k, v] of Object.entries(value)) {
397
- out[k] = expandEnvVars(v, k);
398
- }
399
- return out;
400
- }
401
- return value;
402
- }
403
- function parseConfigObjectFromText(text) {
214
+ function maybeAutoMigrateConfigFile(configPath, text) {
215
+ let obj;
404
216
  try {
405
- const raw = JSON.parse(stripJsonComments(text));
406
- if (typeof raw !== "object" || raw === null || Array.isArray(raw))
407
- return undefined;
408
- return raw;
217
+ obj = parseConfigText(text);
409
218
  }
410
219
  catch {
411
- return undefined;
220
+ return text; // Malformed JSON — let parseAndValidate surface the error.
412
221
  }
413
- }
414
- function writeConfigObject(configPath, config) {
415
- writeFileAtomic(configPath, `${JSON.stringify(config, null, 2)}\n`);
416
- }
417
- /**
418
- * Strip JavaScript-style comments from a JSON string (JSONC support).
419
- * Handles // line comments and /* block comments while preserving
420
- * comment-like sequences inside quoted strings.
421
- */
422
- export function stripJsonComments(text) {
423
- let result = "";
424
- let i = 0;
425
- let inString = false;
426
- while (i < text.length) {
427
- if (inString) {
428
- if (text[i] === "\\") {
429
- result += text[i] + (text[i + 1] ?? "");
430
- i += 2;
431
- continue;
432
- }
433
- if (text[i] === '"') {
434
- inString = false;
435
- }
436
- result += text[i];
437
- i++;
438
- continue;
439
- }
440
- // JSON only uses double-quoted strings; single quotes are not valid JSON
441
- if (text[i] === '"') {
442
- inString = true;
443
- result += text[i];
444
- i++;
445
- continue;
446
- }
447
- if (text[i] === "/" && text[i + 1] === "/") {
448
- while (i < text.length && text[i] !== "\n")
449
- i++;
450
- continue;
451
- }
452
- if (text[i] === "/" && text[i + 1] === "*") {
453
- i += 2;
454
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
455
- i++;
456
- i += 2;
457
- continue;
458
- }
459
- result += text[i];
460
- i++;
222
+ if (compareConfigVersion(obj.configVersion, CURRENT_CONFIG_VERSION) === 1) {
223
+ return text;
461
224
  }
462
- return result;
225
+ const { changed, result } = migrateConfigShape(obj);
226
+ if (!changed)
227
+ return text;
228
+ const migratedText = `${JSON.stringify(result, null, 2)}\n`;
229
+ if (process.env.AKM_NO_AUTO_MIGRATE === "1")
230
+ return migratedText;
231
+ try {
232
+ withConfigLock(() => {
233
+ backupExistingConfig(configPath);
234
+ writeConfigAtomic(configPath, result);
235
+ });
236
+ const newVersion = typeof result.configVersion === "string" ? result.configVersion : "0.8.0";
237
+ const backupDir = `${getCacheDir()}/config-backups`;
238
+ // WS-2: emit a loud banner to BOTH stderr and stdout so pipelines and
239
+ // interactive terminals both see it. Include the backup path (resolved,
240
+ // not ~/...), opt-out env var, and preview diff command.
241
+ const banner = [
242
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
243
+ ` akm: auto-migrated config → ${newVersion} format`,
244
+ ` file: ${configPath}`,
245
+ ` backup: ${backupDir}/config-<timestamp>.json`,
246
+ " to opt out of future auto-migration: AKM_NO_AUTO_MIGRATE=1",
247
+ " to preview a dry-run diff: akm config migrate --dry-run --print-diff",
248
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
249
+ ].join("\n");
250
+ process.stderr.write(`${banner}\n`);
251
+ process.stdout.write(`${banner}\n`);
252
+ }
253
+ catch (err) {
254
+ // #461: never return migrated bytes when disk write fails — that triggers
255
+ // an infinite re-migrate loop on every load. Hard-error so the user
256
+ // notices and either fixes the disk issue or sets AKM_NO_AUTO_MIGRATE=1.
257
+ throw new ConfigError(`Failed to write migrated config to ${configPath}: ${err instanceof Error ? err.message : String(err)}`, "INVALID_CONFIG_FILE", "Check filesystem permissions, free space, and disk health. To skip auto-migration, set AKM_NO_AUTO_MIGRATE=1.");
258
+ }
259
+ return migratedText;
463
260
  }
464
- function parseEmbeddingConfig(value) {
465
- if (typeof value !== "object" || value === null || Array.isArray(value))
466
- return undefined;
467
- const obj = value;
468
- // Extract localModel early — it's valid even without a remote endpoint
469
- const localModel = typeof obj.localModel === "string" && obj.localModel ? obj.localModel : undefined;
470
- // If no endpoint is provided, the config is only valid when localModel is set
471
- // (local-only embedding configuration).
472
- // Sentinel: { endpoint: "", model: "" } means "local-only" — use hasRemoteEndpoint()
473
- // (in embedder.ts) to distinguish from a real remote config. Do NOT check
474
- // endpoint/model directly in consuming code.
475
- if (typeof obj.endpoint !== "string" || !obj.endpoint) {
476
- if (localModel) {
477
- return { endpoint: "", model: "", localModel };
478
- }
479
- return undefined;
480
- }
481
- if (!isValidHttpUrl(obj.endpoint, "embedding config")) {
482
- // Still return localModel-only config if localModel was set
483
- if (localModel) {
484
- return { endpoint: "", model: "", localModel };
485
- }
486
- return undefined;
487
- }
488
- if (typeof obj.model !== "string" || !obj.model) {
489
- // No remote model, but localModel may still be valid
490
- if (localModel) {
491
- warn(`[akm] Embedding endpoint "${obj.endpoint}" ignored: model is required for remote embeddings. Using local model only.`);
492
- return { endpoint: "", model: "", localModel };
493
- }
494
- return undefined;
495
- }
496
- const result = {
497
- endpoint: obj.endpoint,
498
- model: obj.model,
499
- };
500
- if (typeof obj.provider === "string" && obj.provider) {
501
- result.provider = obj.provider;
502
- }
503
- if ("dimension" in obj) {
504
- const dim = parsePositiveInteger("embedding.dimension", obj.dimension);
505
- if (dim === undefined)
506
- return undefined;
507
- result.dimension = dim;
508
- }
509
- if (typeof obj.apiKey === "string" && obj.apiKey) {
510
- result.apiKey = obj.apiKey;
511
- }
512
- if (localModel) {
513
- result.localModel = localModel;
514
- }
515
- if ("contextLength" in obj) {
516
- const ctx = parsePositiveInteger("embedding.contextLength", obj.contextLength);
517
- if (ctx === undefined)
518
- return undefined;
519
- result.contextLength = ctx;
520
- }
521
- if (typeof obj.ollamaOptions === "object" && obj.ollamaOptions !== null && !Array.isArray(obj.ollamaOptions)) {
522
- const opts = obj.ollamaOptions;
523
- const parsed = {};
524
- const numCtx = parsePositiveInteger("embedding.ollamaOptions.num_ctx", opts.num_ctx);
525
- if (numCtx !== undefined) {
526
- parsed.num_ctx = numCtx;
527
- }
528
- if (Object.keys(parsed).length > 0) {
529
- result.ollamaOptions = parsed;
530
- }
531
- }
532
- return result;
261
+ export function loadConfig() {
262
+ // Single-layer load: only the user-level config file is read. Project-level
263
+ // .akm/config.json files discovered under cwd-ancestors emit a one-time
264
+ // deprecation warning (#457) but are NOT merged.
265
+ warnIfProjectConfigPresent(process.cwd());
266
+ return loadUserConfig();
533
267
  }
534
- function parseLlmConfig(value) {
535
- if (typeof value !== "object" || value === null || Array.isArray(value))
536
- return undefined;
537
- const obj = value;
538
- if (typeof obj.endpoint !== "string" || !obj.endpoint)
539
- return undefined;
540
- if (!isValidHttpUrl(obj.endpoint, "llm config")) {
541
- return undefined;
542
- }
543
- if (!obj.endpoint.endsWith("/chat/completions")) {
544
- warn(`[akm] llm.endpoint "${obj.endpoint}" does not end in /chat/completions. ` +
545
- `Did you mean "${obj.endpoint.replace(/\/+$/, "")}/chat/completions"?`);
546
- }
547
- const model = typeof obj.model === "string" ? obj.model : "";
548
- const result = {
549
- endpoint: obj.endpoint,
550
- model,
551
- };
552
- if (typeof obj.provider === "string" && obj.provider) {
553
- result.provider = obj.provider;
554
- }
555
- if (typeof obj.temperature === "number" && Number.isFinite(obj.temperature)) {
556
- result.temperature = obj.temperature;
557
- }
558
- if ("timeoutMs" in obj) {
559
- const t = parsePositiveInteger("llm.timeoutMs", obj.timeoutMs);
560
- if (t === undefined)
561
- return undefined;
562
- result.timeoutMs = t;
563
- }
564
- if ("concurrency" in obj) {
565
- const c = parsePositiveInteger("llm.concurrency", obj.concurrency);
566
- if (c === undefined)
567
- return undefined;
568
- result.concurrency = c;
569
- }
570
- if ("maxTokens" in obj) {
571
- const m = parsePositiveInteger("llm.maxTokens", obj.maxTokens);
572
- if (m === undefined)
573
- return undefined;
574
- result.maxTokens = m;
575
- }
576
- if ("contextLength" in obj) {
577
- const ctx = parsePositiveInteger("llm.contextLength", obj.contextLength);
578
- if (ctx !== undefined)
579
- result.contextLength = ctx;
580
- }
581
- if (typeof obj.apiKey === "string" && obj.apiKey) {
582
- result.apiKey = obj.apiKey;
583
- }
584
- if (typeof obj.capabilities === "object" && obj.capabilities !== null && !Array.isArray(obj.capabilities)) {
585
- const capsRaw = obj.capabilities;
586
- const caps = {};
587
- if (typeof capsRaw.structuredOutput === "boolean")
588
- caps.structuredOutput = capsRaw.structuredOutput;
589
- if (Object.keys(caps).length > 0)
590
- result.capabilities = caps;
591
- }
592
- if (typeof obj.features === "object" && obj.features !== null && !Array.isArray(obj.features)) {
593
- const features = parseLlmFeatures(obj.features);
594
- if (Object.keys(features).length > 0)
595
- result.features = features;
596
- }
597
- if (typeof obj.judgeModel === "string" && obj.judgeModel.trim()) {
598
- result.judgeModel = obj.judgeModel.trim();
599
- }
600
- if (typeof obj.extraParams === "object" && obj.extraParams !== null && !Array.isArray(obj.extraParams)) {
601
- result.extraParams = obj.extraParams;
602
- }
603
- return result;
268
+ export function saveConfig(config) {
269
+ cachedConfig = undefined;
270
+ const configPath = getConfigPath();
271
+ const dir = path.dirname(configPath);
272
+ fs.mkdirSync(dir, { recursive: true });
273
+ const sanitized = sanitizeConfigForWrite(config);
274
+ // Final validation gate before bytes hit disk. Catches schema violations
275
+ // (unknown keys in registries[] / sources[] / profiles.*; out-of-range
276
+ // numbers; etc. — closes #462) before we corrupt the user's config.
277
+ const parseResult = AkmConfigSchema.safeParse(sanitized);
278
+ if (!parseResult.success) {
279
+ const lines = parseResult.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
280
+ throw new ConfigError(`Refusing to save invalid config:\n${lines}`, "INVALID_CONFIG_FILE", "Fix the listed fields, or undo the offending `akm config set`. " +
281
+ "If this looks like an akm bug, re-run with --debug to attach the traceback.");
282
+ }
283
+ // WS-3: acquire the config write lock so concurrent `akm config set`
284
+ // invocations do not interleave their backup+atomic-write cycles.
285
+ withConfigLock(() => {
286
+ backupExistingConfig(configPath);
287
+ writeConfigAtomic(configPath, sanitized);
288
+ });
604
289
  }
605
290
  /**
606
- * v1 spec §14 locked feature keys. Defined here so unknown keys can
607
- * be warn-and-ignored at load time (per spec §14.3 / §9.2). The set is
608
- * deliberately the *full* locked table even though only a subset has
609
- * runtime parsing today; this lets users author future-flagged configs
610
- * without spurious warnings.
291
+ * Strip literal apiKey fields before writing config to disk.
292
+ * API keys are expected to come from environment variables
293
+ * (AKM_EMBED_API_KEY, AKM_LLM_API_KEY, AKM_PROFILE_<NAME>_API_KEY).
294
+ *
295
+ * `${VAR}` / `$VAR` references are preserved — they are not secrets, they
296
+ * are deferred lookups resolved at consumption by `resolveSecret`. Dropping
297
+ * them would break the documented config-on-disk pattern.
298
+ *
299
+ * When a non-reference literal value is stripped, emit a `warn()` so the
300
+ * user knows their key was dropped and how to provide it at runtime (#474).
301
+ * Previously the strip was silent — a user invoking `akm setup --from <file>
302
+ * --yes` with an `apiKey` field expected persistence and got a wiped config
303
+ * with no feedback.
611
304
  */
612
- const LOCKED_LLM_FEATURE_KEYS = new Set([
613
- "curate_rerank",
614
- "feedback_distillation",
615
- "memory_inference",
616
- "graph_extraction",
617
- "memory_consolidation",
618
- "lesson_quality_gate",
619
- "metadata_enhance",
620
- ]);
621
- function parseLlmFeatures(raw) {
622
- const out = {};
623
- for (const [key, value] of Object.entries(raw)) {
624
- if (!LOCKED_LLM_FEATURE_KEYS.has(key)) {
625
- warn(`[akm] Ignoring unknown llm.features key "${key}".`);
626
- continue;
627
- }
628
- if (typeof value !== "boolean") {
629
- warn(`[akm] Ignoring llm.features.${key}: expected boolean, got ${typeof value}.`);
630
- continue;
305
+ function sanitizeConfigForWrite(config) {
306
+ const sanitized = { ...config };
307
+ const stripped = [];
308
+ if (config.embedding?.apiKey !== undefined) {
309
+ const apiKey = config.embedding.apiKey;
310
+ if (isEnvReference(apiKey)) {
311
+ // Preserve reference verbatim — not a secret.
312
+ sanitized.embedding = { ...config.embedding };
631
313
  }
632
- if (LOCKED_LLM_FEATURE_KEYS.has(key)) {
633
- out[key] = value;
314
+ else {
315
+ const { apiKey: _drop, ...rest } = config.embedding;
316
+ sanitized.embedding = rest;
317
+ if (apiKey)
318
+ stripped.push("embedding.apiKey (set AKM_EMBED_API_KEY to provide at runtime)");
634
319
  }
635
320
  }
636
- return out;
637
- }
638
- /**
639
- * Keys that, if present anywhere under `index.<pass>`, indicate the user is
640
- * trying to supply a parallel LLM provider configuration. Per #208 this is
641
- * deliberately rejected at load time so there is exactly one place to
642
- * configure the LLM (`akm.llm`).
643
- */
644
- const PROVIDER_CONFIG_KEYS = new Set([
645
- "endpoint",
646
- "model",
647
- "provider",
648
- "apiKey",
649
- "baseUrl",
650
- "temperature",
651
- "maxTokens",
652
- "capabilities",
653
- ]);
654
- const GRAPH_EXTRACTION_INCLUDE_TYPES_ALLOWED = new Set([
655
- "memory",
656
- "knowledge",
657
- "skill",
658
- "command",
659
- "agent",
660
- "workflow",
661
- "lesson",
662
- "task",
663
- "wiki",
664
- ]);
665
- /**
666
- * Parse the `index` config block. Each entry is a pass name → small object
667
- * `{ llm?: boolean }`. Anything richer (a parallel provider config, unknown
668
- * keys, non-boolean `llm`) throws `ConfigError("INVALID_CONFIG_FILE")` at
669
- * load time so the failure is visible at startup, not on the next index run.
670
- */
671
- function parseIndexConfig(value) {
672
- if (value === undefined || value === null)
673
- return undefined;
674
- if (typeof value !== "object" || Array.isArray(value)) {
675
- throw new ConfigError('Invalid `index` config: expected an object keyed by pass name (e.g. `{ "enrichment": { "llm": false } }`).', "INVALID_CONFIG_FILE");
321
+ else if (config.embedding) {
322
+ sanitized.embedding = { ...config.embedding };
676
323
  }
677
- const out = {};
678
- for (const [passName, raw] of Object.entries(value)) {
679
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
680
- throw new ConfigError(`Invalid \`index.${passName}\` config: expected an object like \`{ "llm": false }\`.`, "INVALID_CONFIG_FILE");
681
- }
682
- const passRaw = raw;
683
- // Reject any provider-shaped key — there must be exactly one place to
684
- // configure the LLM (#208). This is the duplicate-provider guard.
685
- for (const key of Object.keys(passRaw)) {
686
- if (PROVIDER_CONFIG_KEYS.has(key)) {
687
- throw new ConfigError(`Duplicate LLM provider configuration: \`index.${passName}.${key}\` is not allowed. ` +
688
- "Configure provider/model/endpoint under top-level `llm` only; per-pass entries support `{ llm: false }` opt-out.", "INVALID_CONFIG_FILE", 'Move provider settings to the top-level "llm" block, then set `index.<pass>.llm = false` to opt a single pass out.');
689
- }
690
- if (key !== "llm" &&
691
- key !== "graphExtractionBatchSize" &&
692
- key !== "graphExtractionIncludeTypes" &&
693
- key !== "memoryInferenceBatchSize") {
694
- throw new ConfigError(`Unknown key \`index.${passName}.${key}\`. Per-pass entries support \`llm\` (boolean opt-out), \`graphExtractionBatchSize\`, \`graphExtractionIncludeTypes\`, and \`memoryInferenceBatchSize\`.`, "INVALID_CONFIG_FILE");
695
- }
696
- }
697
- const passConfig = {};
698
- if ("llm" in passRaw) {
699
- const llmFlag = passRaw.llm;
700
- if (typeof llmFlag !== "boolean") {
701
- throw new ConfigError(`Invalid \`index.${passName}.llm\`: expected a boolean (true to use \`akm.llm\`, false to opt out). Got ${typeof llmFlag}.`, "INVALID_CONFIG_FILE", "Per-pass alternative provider config is intentionally unsupported in v1 (#208). Use `false` to disable LLM for this pass.");
702
- }
703
- passConfig.llm = llmFlag;
704
- }
705
- if ("graphExtractionBatchSize" in passRaw) {
706
- const n = parsePositiveInteger(`index.${passName}.graphExtractionBatchSize`, passRaw.graphExtractionBatchSize);
707
- if (n !== undefined)
708
- passConfig.graphExtractionBatchSize = n;
709
- }
710
- if ("graphExtractionIncludeTypes" in passRaw) {
711
- const rawTypes = passRaw.graphExtractionIncludeTypes;
712
- if (!Array.isArray(rawTypes) || !rawTypes.every((t) => typeof t === "string" && t.trim().length > 0)) {
713
- throw new ConfigError(`Invalid \`index.${passName}.graphExtractionIncludeTypes\`: expected a non-empty string array of asset types.`, "INVALID_CONFIG_FILE");
324
+ if (config.profiles?.llm) {
325
+ const llmProfiles = {};
326
+ for (const [name, profile] of Object.entries(config.profiles.llm)) {
327
+ if (profile.apiKey !== undefined) {
328
+ if (isEnvReference(profile.apiKey)) {
329
+ llmProfiles[name] = { ...profile };
330
+ }
331
+ else {
332
+ const { apiKey: _drop, ...rest } = profile;
333
+ llmProfiles[name] = rest;
334
+ if (profile.apiKey) {
335
+ const envVar = `AKM_PROFILE_${name.toUpperCase().replace(/-/g, "_")}_API_KEY`;
336
+ stripped.push(`profiles.llm.${name}.apiKey (set ${envVar} to provide at runtime)`);
337
+ }
338
+ }
714
339
  }
715
- const normalized = rawTypes.map((t) => t.trim().toLowerCase());
716
- const invalid = normalized.filter((t) => !GRAPH_EXTRACTION_INCLUDE_TYPES_ALLOWED.has(t));
717
- if (invalid.length > 0) {
718
- throw new ConfigError(`Invalid \`index.${passName}.graphExtractionIncludeTypes\`: unsupported type(s): ${invalid.join(", ")}.`, "INVALID_CONFIG_FILE");
340
+ else {
341
+ llmProfiles[name] = { ...profile };
719
342
  }
720
- passConfig.graphExtractionIncludeTypes = normalized;
721
- }
722
- if ("memoryInferenceBatchSize" in passRaw) {
723
- const n = parsePositiveInteger(`index.${passName}.memoryInferenceBatchSize`, passRaw.memoryInferenceBatchSize);
724
- if (n !== undefined)
725
- passConfig.memoryInferenceBatchSize = n;
726
343
  }
727
- out[passName] = passConfig;
344
+ sanitized.profiles = {
345
+ ...(sanitized.profiles ?? {}),
346
+ llm: llmProfiles,
347
+ };
728
348
  }
729
- return out;
349
+ if (stripped.length > 0) {
350
+ warn(`Config sanitizer dropped API key(s) before writing to disk:\n - ${stripped.join("\n - ")}\n\nakm does not persist API keys to config.json. Set the listed environment variables to provide them at runtime, or use \`\${VAR}\` references in your config to defer lookup. See docs/data-and-telemetry.md.`);
351
+ }
352
+ return sanitized;
730
353
  }
731
- function parseInstalledEntries(value) {
732
- if (!Array.isArray(value))
733
- return undefined;
734
- const entries = value
735
- .map((entry) => parseInstalledStashEntry(entry))
736
- .filter((entry) => entry !== undefined);
737
- return entries.length > 0 ? entries : undefined;
354
+ /** Matches `${VAR}`, `${VAR:-default}`, or `$VAR`. */
355
+ function isEnvReference(value) {
356
+ return /^\$\{[^}]+\}$|^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value);
738
357
  }
739
- function parseInstalledStashEntry(value) {
740
- if (typeof value !== "object" || value === null || Array.isArray(value))
741
- return undefined;
742
- const obj = value;
743
- const id = asNonEmptyString(obj.id);
744
- const source = asKitSource(obj.source);
745
- const ref = asNonEmptyString(obj.ref);
746
- const artifactUrl = asNonEmptyString(obj.artifactUrl);
747
- const stashRoot = asNonEmptyString(obj.stashRoot);
748
- const cacheDir = asNonEmptyString(obj.cacheDir);
749
- const installedAt = asNonEmptyString(obj.installedAt);
750
- if (!id || !source || !ref || !artifactUrl || !stashRoot || !cacheDir || !installedAt)
751
- return undefined;
752
- const entry = {
753
- id,
754
- source,
755
- ref,
756
- artifactUrl,
757
- stashRoot,
758
- cacheDir,
759
- installedAt,
760
- };
761
- if (typeof obj.writable === "boolean")
762
- entry.writable = obj.writable;
763
- if (entry.writable === true && entry.source !== "git") {
764
- throw new ConfigError(`writable: true is only supported on filesystem and git sources (got "${entry.source}" on installed entry "${entry.id}").`, "INVALID_CONFIG_FILE", "Remove `writable: true` from the installed entry or re-add it as a git source instead.");
765
- }
766
- const resolvedVersion = asNonEmptyString(obj.resolvedVersion);
767
- if (resolvedVersion)
768
- entry.resolvedVersion = resolvedVersion;
769
- const resolvedRevision = asNonEmptyString(obj.resolvedRevision);
770
- if (resolvedRevision)
771
- entry.resolvedRevision = resolvedRevision;
772
- const wikiName = asNonEmptyString(obj.wikiName);
773
- if (wikiName)
774
- entry.wikiName = wikiName;
775
- return entry;
358
+ export function updateConfig(partial) {
359
+ const current = loadUserConfig();
360
+ const merged = mergeLoadedConfig(current, partial);
361
+ saveConfig(merged);
362
+ return merged;
776
363
  }
364
+ // ── Helpers ─────────────────────────────────────────────────────────────────
777
365
  /**
778
- * Validate a legacy lockfile/installed-entry source string.
366
+ * Resolve a single secret value by expanding `${VAR}` / `$VAR` /
367
+ * `${VAR:-default}` references against `process.env`. Use this at apiKey /
368
+ * authorization-header consumption sites (LLM client, embedder, agent SDK
369
+ * runner) — NOT on the load path. Non-string inputs pass through unchanged.
779
370
  *
780
- * Restricted to the four kinds that the install pipeline produces
781
- * (`"npm" | "github" | "git" | "local"`). The full {@link KitSource} union is
782
- * wider, but persisted `installed[]` entries should never carry the runtime
783
- * provider kinds (`"filesystem" | "website"`).
371
+ * Returns the input unchanged when no substitution markers are present, so
372
+ * literal API key strings (already-resolved secrets) are zero-cost.
373
+ *
374
+ * Other config string values (URLs, endpoints, model names, prompts) are
375
+ * preserved verbatim on read — only fields explicitly routed through this
376
+ * helper are expanded.
784
377
  */
785
- function asKitSource(value) {
786
- if (value === "npm" || value === "github" || value === "git" || value === "local")
787
- return value;
788
- return undefined;
789
- }
790
- function parseRegistriesConfig(value) {
791
- if (!Array.isArray(value))
792
- return undefined;
793
- const entries = value
794
- .map((entry) => parseRegistryConfigEntry(entry))
795
- .filter((entry) => entry !== undefined);
796
- // Return the array even if empty — an explicit empty array means "no registries"
797
- // which overrides the default. Only return undefined if the field was not an array.
798
- return entries;
799
- }
800
- function parseSourcesConfig(value) {
801
- if (!Array.isArray(value))
378
+ export function resolveSecret(value) {
379
+ if (value === undefined)
802
380
  return undefined;
803
- const entries = value
804
- .map((entry) => parseSourceConfigEntry(entry))
805
- .filter((entry) => entry !== undefined);
806
- return entries;
807
- }
808
- function parseSecurityConfig(value) {
809
- if (typeof value !== "object" || value === null || Array.isArray(value))
810
- return undefined;
811
- const obj = value;
812
- const installAudit = parseInstallAuditConfig(obj.installAudit);
813
- if (!installAudit)
814
- return undefined;
815
- return { installAudit };
816
- }
817
- function parseInstallAuditConfig(value) {
818
- if (typeof value !== "object" || value === null || Array.isArray(value))
819
- return undefined;
820
- const obj = value;
821
- const config = {};
822
- if (typeof obj.enabled === "boolean")
823
- config.enabled = obj.enabled;
824
- if (typeof obj.blockOnCritical === "boolean")
825
- config.blockOnCritical = obj.blockOnCritical;
826
- if (typeof obj.blockUnlistedRegistries === "boolean")
827
- config.blockUnlistedRegistries = obj.blockUnlistedRegistries;
828
- const rawAllowlist = filterNonEmptyStrings(obj.registryAllowlist) ?? filterNonEmptyStrings(obj.registryWhitelist);
829
- if (!obj.registryAllowlist && obj.registryWhitelist) {
830
- warn("[akm] config: `registryWhitelist` is deprecated; rename it to `registryAllowlist`");
831
- }
832
- if (rawAllowlist) {
833
- config.registryAllowlist = rawAllowlist;
834
- }
835
- const allowedFindings = parseInstallAuditAllowedFindings(obj.allowedFindings);
836
- if (allowedFindings) {
837
- config.allowedFindings = allowedFindings;
838
- }
839
- return Object.keys(config).length > 0 ? config : undefined;
840
- }
841
- function parseInstallAuditAllowedFindings(value) {
842
- if (!Array.isArray(value))
843
- return undefined;
844
- const findings = value
845
- .map((entry) => parseInstallAuditAllowedFinding(entry))
846
- .filter((entry) => entry !== undefined);
847
- return findings.length > 0 ? findings : undefined;
848
- }
849
- function parseInstallAuditAllowedFinding(value) {
850
- if (typeof value !== "object" || value === null || Array.isArray(value))
851
- return undefined;
852
- const obj = value;
853
- const id = asNonEmptyString(obj.id);
854
- if (!id)
855
- return undefined;
856
- const finding = { id };
857
- const ref = asNonEmptyString(obj.ref);
858
- if (ref)
859
- finding.ref = ref;
860
- const entryPath = asNonEmptyString(obj.path);
861
- if (entryPath)
862
- finding.path = entryPath;
863
- const reason = asNonEmptyString(obj.reason);
864
- if (reason)
865
- finding.reason = reason;
866
- return finding;
867
- }
868
- function parseSourceConfigEntry(value) {
869
- if (typeof value !== "object" || value === null || Array.isArray(value))
870
- return undefined;
871
- const obj = value;
872
- const type = asNonEmptyString(obj.type);
873
- if (!type)
874
- return undefined;
875
- if (type === "openviking") {
876
- const name = asNonEmptyString(obj.name) ?? "unnamed";
877
- throw new ConfigError(`openviking is not supported in akm v1. API-backed sources will return as a\nseparate QuerySource tier post-v1. Remove the source named "${name}" from your config file\nor downgrade to 0.6.x. See docs/migration/v1.md.`, "INVALID_CONFIG_FILE", `Run \`akm remove ${name}\` then re-run, or edit your config file directly at ${getConfigPath()} to remove the openviking entry.`);
878
- }
879
- const entry = { type };
880
- const entryPath = asNonEmptyString(obj.path);
881
- if (entryPath)
882
- entry.path = entryPath;
883
- const url = asNonEmptyString(obj.url);
884
- if (url)
885
- entry.url = url;
886
- const name = asNonEmptyString(obj.name);
887
- if (name)
888
- entry.name = name;
889
- if (typeof obj.enabled === "boolean")
890
- entry.enabled = obj.enabled;
891
- if (typeof obj.writable === "boolean")
892
- entry.writable = obj.writable;
893
- if (typeof obj.primary === "boolean")
894
- entry.primary = obj.primary;
895
- // Locked decision 4 (§6 v1 implementation plan): reject writable: true on
896
- // website / npm sources at config load. The next sync() would clobber
897
- // writes — allowing this is a footgun, not a feature. Throw early so the
898
- // user sees the problem at `akm` startup, not when they try to write.
899
- if (entry.writable === true && (type === "website" || type === "npm")) {
900
- const label = entry.name ? ` "${entry.name}"` : "";
901
- throw new ConfigError(`writable: true is only supported on filesystem and git sources (got "${type}" on source${label}).`, "INVALID_CONFIG_FILE", "To author into a checked-out package, add the same path as a separate filesystem source.");
902
- }
903
- if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
904
- entry.options = obj.options;
905
- }
906
- const wikiName = asNonEmptyString(obj.wikiName);
907
- if (wikiName)
908
- entry.wikiName = wikiName;
909
- return entry;
910
- }
911
- // ── ConfiguredSource runtime construction ─────────────────────────────────────────
912
- /**
913
- * Synthesize a stable identifier when a {@link SourceConfigEntry} omits its
914
- * `name`. Uses a short hash of the discriminating fields so two equivalent
915
- * entries collapse to the same generated name.
916
- */
917
- function deriveStashEntryName(entry) {
918
- if (entry.name)
919
- return entry.name;
920
- const seed = JSON.stringify({
921
- type: entry.type,
922
- path: entry.path ?? null,
923
- url: entry.url ?? null,
381
+ if (typeof value !== "string")
382
+ return value;
383
+ if (!value.includes("$"))
384
+ return value;
385
+ return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
386
+ if (braced) {
387
+ const [name, ...rest] = braced.split(":-");
388
+ const fallback = rest.join(":-");
389
+ return process.env[name] ?? fallback ?? "";
390
+ }
391
+ return process.env[bare] ?? "";
924
392
  });
925
- const hash = createHash("sha256").update(seed).digest("hex").slice(0, 8);
926
- return `${entry.type}-${hash}`;
927
393
  }
928
394
  /**
929
- * Convert a persisted {@link SourceConfigEntry} into the runtime
930
- * {@link SourceSpec} discriminated union. Returns `undefined` when the
931
- * entry is missing the fields its provider type requires (e.g. a
932
- * `filesystem` entry with no `path`); callers should drop or warn for those.
933
- *
934
- * Unknown provider types fall back to `{ type: "filesystem", path: ... }` when
935
- * a `path` is supplied, so future provider types still produce a usable
936
- * runtime value.
395
+ * Read a per-pass {@link IndexPassConfig} entry from {@link IndexConfig},
396
+ * filtering out the reserved feature-section keys so callers don't mistake
397
+ * `metadataEnhance` / `stalenessDetection` for a pass.
937
398
  */
938
- export function parseSourceSpec(entry) {
939
- switch (entry.type) {
940
- case "filesystem":
941
- return entry.path ? { type: "filesystem", path: entry.path } : undefined;
942
- case "git":
943
- return entry.url ? { type: "git", url: entry.url } : undefined;
944
- case "website":
945
- return entry.url
946
- ? {
947
- type: "website",
948
- url: entry.url,
949
- ...(typeof entry.options?.maxPages === "number" ? { maxPages: entry.options.maxPages } : {}),
950
- }
951
- : undefined;
952
- case "npm":
953
- // Persisted `npm` stash entries are unusual but supported for symmetry.
954
- return entry.path ? { type: "npm", package: entry.path } : undefined;
955
- default:
956
- // Unknown provider — best-effort fallback so callers still get something.
957
- return entry.path ? { type: "filesystem", path: entry.path } : undefined;
958
- }
959
- }
960
- /**
961
- * Build the full ordered list of runtime {@link ConfiguredSource} values from a
962
- * loaded {@link AkmConfig}. Order is the canonical iteration order:
963
- *
964
- * 1. The entry marked `primary: true` (or, as a backwards-compat shim,
965
- * a synthetic filesystem entry built from the top-level `stashDir`).
966
- * 2. Remaining `sources[]` entries in declared order.
967
- * 3. Legacy `installed[]` entries, mapped into runtime entries.
968
- *
969
- * Entries with `enabled: false` are still emitted — callers decide whether
970
- * to honour the flag (mirrors how `installed[]` entries have always been
971
- * unconditional). Entries that fail {@link parseSourceSpec} are
972
- * dropped silently.
973
- */
974
- export function resolveConfiguredSources(config) {
975
- const entries = [];
976
- const sources = config.sources ?? [];
977
- // (1) Primary entry: explicit `primary: true` wins; fall back to top-level stashDir.
978
- let primary = sources.find((entry) => entry.primary === true);
979
- if (!primary && config.stashDir) {
980
- primary = { type: "filesystem", path: config.stashDir, primary: true };
981
- }
982
- if (primary) {
983
- const runtime = toConfiguredSource(primary, true);
984
- if (runtime)
985
- entries.push(runtime);
986
- }
987
- // (2) Declared sources (skip the primary entry — already added).
988
- for (const entry of sources) {
989
- if (entry === primary)
990
- continue;
991
- const runtime = toConfiguredSource(entry, false);
992
- if (runtime)
993
- entries.push(runtime);
994
- }
995
- // (3) Legacy installed[] entries.
996
- for (const installed of config.installed ?? []) {
997
- entries.push({
998
- name: installed.id,
999
- type: "filesystem",
1000
- source: { type: "filesystem", path: installed.stashRoot },
1001
- enabled: true,
1002
- writable: installed.writable,
1003
- ...(installed.wikiName ? { wikiName: installed.wikiName } : {}),
1004
- });
1005
- }
1006
- return entries;
1007
- }
1008
- function toConfiguredSource(persisted, isPrimary) {
1009
- const source = parseSourceSpec(persisted);
1010
- if (!source)
399
+ /** Reserved well-known keys on IndexConfig that are NOT per-pass entries. */
400
+ const INDEX_RESERVED_KEYS = new Set(["metadataEnhance", "stalenessDetection"]);
401
+ export function getIndexPassConfig(config, passName) {
402
+ if (!config)
1011
403
  return undefined;
1012
- return {
1013
- name: deriveStashEntryName(persisted),
1014
- type: persisted.type,
1015
- source,
1016
- ...(persisted.enabled !== undefined ? { enabled: persisted.enabled } : {}),
1017
- ...(persisted.writable !== undefined ? { writable: persisted.writable } : {}),
1018
- ...(isPrimary || persisted.primary ? { primary: true } : {}),
1019
- ...(persisted.options ? { options: persisted.options } : {}),
1020
- ...(persisted.wikiName ? { wikiName: persisted.wikiName } : {}),
1021
- };
1022
- }
1023
- function parseRegistryConfigEntry(value) {
1024
- if (typeof value !== "object" || value === null || Array.isArray(value))
404
+ if (INDEX_RESERVED_KEYS.has(passName))
1025
405
  return undefined;
1026
- const obj = value;
1027
- const url = asNonEmptyString(obj.url);
1028
- if (!url?.startsWith("http"))
406
+ const entry = config[passName];
407
+ if (!entry || typeof entry !== "object")
1029
408
  return undefined;
1030
- const entry = { url };
1031
- const name = asNonEmptyString(obj.name);
1032
- if (name)
1033
- entry.name = name;
1034
- if (typeof obj.enabled === "boolean")
1035
- entry.enabled = obj.enabled;
1036
- const provider = asNonEmptyString(obj.provider);
1037
- if (provider)
1038
- entry.provider = provider;
1039
- if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
1040
- entry.options = obj.options;
1041
- }
1042
409
  return entry;
1043
410
  }
1044
- function mergeAgentConfig(base, override) {
1045
- const merged = { ...base, ...override };
1046
- const baseProfiles = base.profiles;
1047
- const overrideProfiles = override.profiles;
1048
- if (baseProfiles && overrideProfiles) {
1049
- const profiles = { ...baseProfiles };
1050
- for (const [name, entry] of Object.entries(overrideProfiles)) {
1051
- const existing = baseProfiles[name];
1052
- profiles[name] = existing ? { ...existing, ...entry } : entry;
1053
- }
1054
- merged.profiles = profiles;
1055
- }
1056
- // Shallow merge per-key: later layer wins per process name (same as profiles).
1057
- const baseProcesses = base.processes;
1058
- const overrideProcesses = override.processes;
1059
- if (baseProcesses || overrideProcesses) {
1060
- merged.processes = { ...(baseProcesses ?? {}), ...(overrideProcesses ?? {}) };
1061
- }
1062
- return merged;
1063
- }
1064
- function mergeSecurityConfig(base, override) {
1065
- if (!base && !override)
1066
- return undefined;
1067
- const installAudit = mergeInstallAuditConfig(base?.installAudit, override?.installAudit);
1068
- return installAudit ? { installAudit } : undefined;
1069
- }
1070
- function mergeInstallAuditConfig(base, override) {
1071
- if (!base && !override)
1072
- return undefined;
1073
- const merged = {
1074
- ...(base ?? {}),
1075
- ...(override ?? {}),
1076
- };
1077
- return Object.values(merged).some((value) => value !== undefined) ? merged : undefined;
1078
- }
411
+ // Re-export source runtime helpers — implementation lives in config-sources.ts.
412
+ export { parseSourceSpec, resolveConfiguredSources } from "./config-sources";
1079
413
  /**
1080
- * Merge a normalized config layer into an accumulated config.
1081
- *
1082
- * Scalar fields follow normal override semantics. Known nested objects are
1083
- * deep-merged so project config files can override individual fields without
1084
- * clobbering sibling settings. `sources` are additive by default, but a later
1085
- * layer can set `stashInheritance: "replace"` to drop inherited sources first.
414
+ * Merge a partial user-config override onto a base config. Used by
415
+ * {@link loadUserConfig} (DEFAULT_CONFIG + on-disk) and {@link updateConfig}
416
+ * (current config + partial patch). Sub-objects with named records (profiles,
417
+ * defaults, etc.) shallow-merge; arrays override wholesale.
1086
418
  */
1087
419
  function mergeLoadedConfig(base, override) {
1088
420
  if (!override)
1089
421
  return { ...base };
1090
- const merged = {
1091
- ...base,
1092
- ...override,
1093
- };
1094
- if (base.output && override.output) {
1095
- merged.output = { ...base.output, ...override.output };
1096
- }
1097
- if (base.embedding && override.embedding) {
1098
- merged.embedding = { ...base.embedding, ...override.embedding };
1099
- }
1100
- if (base.llm && override.llm) {
1101
- merged.llm = { ...base.llm, ...override.llm };
1102
- }
1103
- if (base.index || override.index) {
1104
- // Deep-merge per-pass entries so a project layer can opt one pass out
1105
- // without dropping siblings configured in user config.
1106
- const mergedIndex = { ...(base.index ?? {}) };
1107
- for (const [passName, passOverride] of Object.entries(override.index ?? {})) {
1108
- mergedIndex[passName] = { ...(mergedIndex[passName] ?? {}), ...passOverride };
422
+ const merged = { ...base, ...override };
423
+ // Shallow-merge sub-objects so a partial update to e.g. `output.format`
424
+ // doesn't drop the existing `output.detail`.
425
+ for (const key of ["output", "embedding", "index", "defaults"]) {
426
+ if (base[key] && override[key]) {
427
+ // biome-ignore lint/suspicious/noExplicitAny: heterogeneous structural merge
428
+ merged[key] = { ...base[key], ...override[key] };
1109
429
  }
1110
- if (Object.keys(mergedIndex).length > 0)
1111
- merged.index = mergedIndex;
1112
- }
1113
- if (base.security && override.security) {
1114
- merged.security = mergeSecurityConfig(base.security, override.security);
1115
430
  }
1116
- if (base.agent && override.agent) {
1117
- merged.agent = mergeAgentConfig(base.agent, override.agent);
1118
- }
1119
- const replaceSources = override.stashInheritance === "replace";
1120
- const overrideSources = override.sources ?? [];
1121
- const baseSources = base.sources ?? [];
1122
- if (replaceSources) {
1123
- merged.sources = [...overrideSources];
1124
- }
1125
- else if (overrideSources.length > 0) {
1126
- merged.sources = [...baseSources, ...overrideSources];
1127
- }
1128
- else if (baseSources.length > 0) {
1129
- merged.sources = [...baseSources];
431
+ if (base.profiles && override.profiles) {
432
+ const next = { ...base.profiles };
433
+ for (const k of ["llm", "agent", "improve"]) {
434
+ const ovr = override.profiles[k];
435
+ if (ovr)
436
+ next[k] = { ...(next[k] ?? {}), ...ovr };
437
+ }
438
+ merged.profiles = next;
1130
439
  }
1131
440
  return merged;
1132
441
  }
@@ -1137,51 +446,51 @@ function applyRuntimeEnvApiKeys(config) {
1137
446
  if (envKey)
1138
447
  next.embedding = { ...next.embedding, apiKey: envKey };
1139
448
  }
1140
- if (next.llm && !next.llm.apiKey) {
1141
- const envKey = process.env.AKM_LLM_API_KEY?.trim();
1142
- if (envKey)
1143
- next.llm = { ...next.llm, apiKey: envKey };
449
+ // LLM profile keys: AKM_LLM_API_KEY for the default profile, then
450
+ // AKM_PROFILE_<UPPER>_API_KEY for any profile (per-profile wins).
451
+ const defaultProfile = next.defaults?.llm;
452
+ if (next.profiles?.llm) {
453
+ const updated = { ...next.profiles.llm };
454
+ let changed = false;
455
+ for (const [name, profile] of Object.entries(updated)) {
456
+ if (profile.apiKey)
457
+ continue;
458
+ const perProfile = process.env[`AKM_PROFILE_${name.toUpperCase().replace(/-/g, "_")}_API_KEY`]?.trim();
459
+ const fallback = name === defaultProfile ? process.env.AKM_LLM_API_KEY?.trim() : undefined;
460
+ const envKey = perProfile || fallback;
461
+ if (envKey) {
462
+ updated[name] = { ...profile, apiKey: envKey };
463
+ changed = true;
464
+ }
465
+ }
466
+ if (changed)
467
+ next.profiles = { ...next.profiles, llm: updated };
1144
468
  }
1145
469
  return next;
1146
470
  }
1147
471
  /**
1148
- * Return config file paths in merge order: user config first, then project
1149
- * config files from the outermost parent directory down to the current working
1150
- * directory. Later entries have higher precedence when merged.
1151
- */
1152
- function getEffectiveConfigPaths() {
1153
- const configPath = getConfigPath();
1154
- const paths = [];
1155
- if (isFile(configPath)) {
1156
- paths.push(configPath);
1157
- }
1158
- return [...paths, ...discoverProjectConfigPaths(process.cwd())];
1159
- }
1160
- /**
1161
- * Walk from `startDir` up to the filesystem root and collect `.akm/config.json`
1162
- * files. Paths are returned from outermost parent to innermost directory so
1163
- * nearer project directories override broader project settings.
472
+ * Walk cwd-ancestors looking for `.akm/config.json`. If one is found, emit a
473
+ * one-time deprecation warning per path. The file's contents are NOT read
474
+ * multi-layer project config was removed in this release; the warning stays
475
+ * for one cycle so users notice they have a now-dead file on disk and can
476
+ * migrate its settings to the user-level config.
1164
477
  */
1165
- function discoverProjectConfigPaths(startDir) {
1166
- const paths = [];
478
+ const PROJECT_CONFIG_DEPRECATION_WARNED = new Set();
479
+ function warnIfProjectConfigPresent(startDir) {
1167
480
  let currentDir = path.resolve(startDir);
1168
481
  while (true) {
1169
482
  const configPath = path.join(currentDir, PROJECT_CONFIG_RELATIVE_PATH);
1170
- if (isFile(configPath)) {
1171
- paths.unshift(configPath);
483
+ if (isFile(configPath) && !PROJECT_CONFIG_DEPRECATION_WARNED.has(configPath)) {
484
+ PROJECT_CONFIG_DEPRECATION_WARNED.add(configPath);
485
+ warn(`[akm] DEPRECATED: project-level config file found at ${configPath}. ` +
486
+ "Project-level config files are no longer merged (removed after 0.8.x deprecation). " +
487
+ "Move any needed settings to ~/.config/akm/config.json; this file is ignored.");
1172
488
  }
1173
489
  const parentDir = path.dirname(currentDir);
1174
- if (parentDir === currentDir) {
490
+ if (parentDir === currentDir)
1175
491
  break;
1176
- }
1177
492
  currentDir = parentDir;
1178
493
  }
1179
- return paths;
1180
- }
1181
- function getConfigSignature(configPaths) {
1182
- if (configPaths.length === 0)
1183
- return "defaults";
1184
- return configPaths.map((configPath) => `${configPath}:${getFileSignatureToken(configPath)}`).join("|");
1185
494
  }
1186
495
  function isFile(filePath) {
1187
496
  try {
@@ -1191,23 +500,3 @@ function isFile(filePath) {
1191
500
  return false;
1192
501
  }
1193
502
  }
1194
- function getFileSignatureToken(filePath) {
1195
- try {
1196
- const stat = fs.statSync(filePath);
1197
- // mtimeMs alone is unreliable on filesystems with low-resolution mtime
1198
- // (HFS+, some network FS, or very fast back-to-back writes in tests).
1199
- // Combine mtime + size + content hash so the signature actually changes
1200
- // when content does.
1201
- let contentHash = "";
1202
- try {
1203
- contentHash = hashString(fs.readFileSync(filePath, "utf8"));
1204
- }
1205
- catch {
1206
- // ignore — fall back to stat-only signature
1207
- }
1208
- return `${stat.mtimeMs}:${stat.size}:${contentHash}`;
1209
- }
1210
- catch {
1211
- return "missing";
1212
- }
1213
- }