akm-cli 0.7.4 → 0.8.0-rc.10

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 (300) hide show
  1. package/CHANGELOG.md +224 -1
  2. package/README.md +22 -6
  3. package/SECURITY.md +93 -0
  4. package/dist/cli/config-migrate.js +144 -0
  5. package/dist/cli/config-validate.js +39 -0
  6. package/dist/cli/confirm.js +73 -0
  7. package/dist/cli/parse-args.js +133 -0
  8. package/dist/cli/shared.js +129 -0
  9. package/dist/cli.js +2631 -1440
  10. package/dist/commands/add-cli.js +279 -0
  11. package/dist/commands/agent-dispatch.js +110 -0
  12. package/dist/commands/agent-support.js +68 -0
  13. package/dist/commands/completions.js +3 -0
  14. package/dist/commands/config-cli.js +130 -534
  15. package/dist/commands/consolidate.js +2122 -0
  16. package/dist/commands/curate.js +45 -3
  17. package/dist/commands/db-cli.js +23 -0
  18. package/dist/commands/distill-promotion-policy.js +660 -0
  19. package/dist/commands/distill.js +1081 -73
  20. package/dist/commands/env.js +213 -0
  21. package/dist/commands/eval-cases.js +43 -0
  22. package/dist/commands/events.js +15 -24
  23. package/dist/commands/extract-cli.js +127 -0
  24. package/dist/commands/extract-prompt.js +204 -0
  25. package/dist/commands/extract.js +477 -0
  26. package/dist/commands/feedback-cli.js +331 -0
  27. package/dist/commands/graph.js +477 -0
  28. package/dist/commands/health.js +1302 -0
  29. package/dist/commands/help/help-accept.md +12 -0
  30. package/dist/commands/help/help-improve.md +69 -0
  31. package/dist/commands/help/help-proposals.md +18 -0
  32. package/dist/commands/help/help-propose.md +17 -0
  33. package/dist/commands/help/help-reject.md +11 -0
  34. package/dist/commands/history.js +54 -46
  35. package/dist/commands/improve-auto-accept.js +97 -0
  36. package/dist/commands/improve-cli.js +217 -0
  37. package/dist/commands/improve-profiles.js +166 -0
  38. package/dist/commands/improve-result-file.js +167 -0
  39. package/dist/commands/improve.js +2373 -0
  40. package/dist/commands/info.js +5 -2
  41. package/dist/commands/init.js +50 -2
  42. package/dist/commands/installed-stashes.js +102 -139
  43. package/dist/commands/knowledge.js +136 -0
  44. package/dist/commands/lint/agent-linter.js +49 -0
  45. package/dist/commands/lint/base-linter.js +479 -0
  46. package/dist/commands/lint/command-linter.js +49 -0
  47. package/dist/commands/lint/default-linter.js +16 -0
  48. package/dist/commands/lint/env-key-rules.js +154 -0
  49. package/dist/commands/lint/index.js +196 -0
  50. package/dist/commands/lint/knowledge-linter.js +16 -0
  51. package/dist/commands/lint/markdown-insertion.js +343 -0
  52. package/dist/commands/lint/memory-linter.js +61 -0
  53. package/dist/commands/lint/registry.js +36 -0
  54. package/dist/commands/lint/skill-linter.js +45 -0
  55. package/dist/commands/lint/task-linter.js +50 -0
  56. package/dist/commands/lint/types.js +4 -0
  57. package/dist/commands/lint/workflow-linter.js +56 -0
  58. package/dist/commands/lint.js +4 -0
  59. package/dist/commands/migration-help.js +3 -0
  60. package/dist/commands/proposal.js +67 -12
  61. package/dist/commands/propose.js +120 -45
  62. package/dist/commands/reflect.js +1104 -60
  63. package/dist/commands/registry-cli.js +150 -0
  64. package/dist/commands/registry-search.js +5 -2
  65. package/dist/commands/remember-cli.js +257 -0
  66. package/dist/commands/remember.js +70 -7
  67. package/dist/commands/schema-repair.js +203 -0
  68. package/dist/commands/search.js +115 -14
  69. package/dist/commands/secret.js +173 -0
  70. package/dist/commands/self-update.js +3 -0
  71. package/dist/commands/show.js +158 -60
  72. package/dist/commands/source-add.js +17 -45
  73. package/dist/commands/source-clone.js +3 -0
  74. package/dist/commands/source-manage.js +14 -19
  75. package/dist/commands/tasks.js +437 -0
  76. package/dist/commands/url-checker.js +42 -0
  77. package/dist/core/action-contributors.js +28 -0
  78. package/dist/core/asset-ref.js +17 -2
  79. package/dist/core/asset-registry.js +12 -17
  80. package/dist/core/asset-serialize.js +88 -0
  81. package/dist/core/asset-spec.js +67 -1
  82. package/dist/core/common.js +182 -0
  83. package/dist/core/concurrent.js +25 -0
  84. package/dist/core/config-io.js +347 -0
  85. package/dist/core/config-migration.js +622 -0
  86. package/dist/core/config-schema.js +534 -0
  87. package/dist/core/config-sources.js +108 -0
  88. package/dist/core/config-types.js +4 -0
  89. package/dist/core/config-walker.js +337 -0
  90. package/dist/core/config.js +364 -968
  91. package/dist/core/errors.js +42 -20
  92. package/dist/core/events.js +105 -135
  93. package/dist/core/file-lock.js +104 -0
  94. package/dist/core/frontmatter.js +75 -8
  95. package/dist/core/lesson-lint.js +3 -0
  96. package/dist/core/markdown.js +20 -0
  97. package/dist/core/memory-belief.js +62 -0
  98. package/dist/core/memory-contradiction-detect.js +274 -0
  99. package/dist/core/memory-improve.js +806 -0
  100. package/dist/core/parse.js +158 -0
  101. package/dist/core/paths.js +280 -14
  102. package/dist/core/proposal-quality-validators.js +380 -0
  103. package/dist/core/proposal-validators.js +69 -0
  104. package/dist/core/proposals.js +512 -42
  105. package/dist/core/state-db.js +1068 -0
  106. package/dist/core/text-truncation.js +107 -0
  107. package/dist/core/time.js +54 -0
  108. package/dist/core/tty.js +59 -0
  109. package/dist/core/warn.js +64 -1
  110. package/dist/core/write-source.js +3 -0
  111. package/dist/indexer/db-backup.js +391 -0
  112. package/dist/indexer/db-search.js +198 -489
  113. package/dist/indexer/db.js +990 -108
  114. package/dist/indexer/ensure-index.js +136 -0
  115. package/dist/indexer/file-context.js +3 -0
  116. package/dist/indexer/graph-boost.js +376 -101
  117. package/dist/indexer/graph-db.js +391 -0
  118. package/dist/indexer/graph-dedup.js +95 -0
  119. package/dist/indexer/graph-extraction.js +550 -114
  120. package/dist/indexer/index-context.js +4 -0
  121. package/dist/indexer/indexer.js +547 -309
  122. package/dist/indexer/llm-cache.js +52 -0
  123. package/dist/indexer/manifest.js +3 -0
  124. package/dist/indexer/matchers.js +167 -160
  125. package/dist/indexer/memory-inference.js +152 -74
  126. package/dist/indexer/metadata-contributors.js +29 -0
  127. package/dist/indexer/metadata.js +275 -196
  128. package/dist/indexer/path-resolver.js +92 -0
  129. package/dist/indexer/project-context.js +192 -0
  130. package/dist/indexer/ranking-contributors.js +331 -0
  131. package/dist/indexer/ranking.js +81 -0
  132. package/dist/indexer/search-fields.js +5 -9
  133. package/dist/indexer/search-hit-enrichers.js +111 -0
  134. package/dist/indexer/search-source.js +44 -10
  135. package/dist/indexer/semantic-status.js +6 -17
  136. package/dist/indexer/staleness-detect.js +447 -0
  137. package/dist/indexer/usage-events.js +12 -9
  138. package/dist/indexer/walker.js +28 -0
  139. package/dist/integrations/agent/builders.js +135 -0
  140. package/dist/integrations/agent/config.js +122 -230
  141. package/dist/integrations/agent/detect.js +3 -0
  142. package/dist/integrations/agent/index.js +7 -13
  143. package/dist/integrations/agent/model-aliases.js +55 -0
  144. package/dist/integrations/agent/profiles.js +70 -5
  145. package/dist/integrations/agent/prompts.js +250 -36
  146. package/dist/integrations/agent/runner.js +151 -0
  147. package/dist/integrations/agent/sdk-runner.js +126 -0
  148. package/dist/integrations/agent/spawn.js +183 -35
  149. package/dist/integrations/github.js +3 -0
  150. package/dist/integrations/lockfile.js +32 -69
  151. package/dist/integrations/session-logs/index.js +69 -0
  152. package/dist/integrations/session-logs/inline-refs.js +35 -0
  153. package/dist/integrations/session-logs/pre-filter.js +152 -0
  154. package/dist/integrations/session-logs/providers/claude-code.js +282 -0
  155. package/dist/integrations/session-logs/providers/opencode.js +258 -0
  156. package/dist/integrations/session-logs/types.js +4 -0
  157. package/dist/llm/call-ai.js +62 -0
  158. package/dist/llm/client.js +79 -88
  159. package/dist/llm/embedder.js +20 -29
  160. package/dist/llm/embedders/cache.js +3 -7
  161. package/dist/llm/embedders/local.js +42 -1
  162. package/dist/llm/embedders/remote.js +20 -8
  163. package/dist/llm/embedders/types.js +3 -7
  164. package/dist/llm/feature-gate.js +95 -48
  165. package/dist/llm/graph-extract.js +676 -72
  166. package/dist/llm/index-passes.js +44 -29
  167. package/dist/llm/memory-infer.js +80 -71
  168. package/dist/llm/metadata-enhance.js +42 -29
  169. package/dist/llm/prompts/extract-session.md +80 -0
  170. package/dist/llm/prompts/graph-extract-user-prompt.md +35 -0
  171. package/dist/output/cli-hints-full.md +292 -0
  172. package/dist/output/cli-hints-short.md +66 -0
  173. package/dist/output/cli-hints.js +7 -311
  174. package/dist/output/context.js +60 -8
  175. package/dist/output/renderers.js +306 -258
  176. package/dist/output/shapes/curate.js +56 -0
  177. package/dist/output/shapes/distill.js +10 -0
  178. package/dist/output/shapes/env-list.js +19 -0
  179. package/dist/output/shapes/events.js +11 -0
  180. package/dist/output/shapes/helpers.js +424 -0
  181. package/dist/output/shapes/history.js +7 -0
  182. package/dist/output/shapes/passthrough.js +102 -0
  183. package/dist/output/shapes/proposal-accept.js +7 -0
  184. package/dist/output/shapes/proposal-diff.js +7 -0
  185. package/dist/output/shapes/proposal-list.js +7 -0
  186. package/dist/output/shapes/proposal-producer.js +11 -0
  187. package/dist/output/shapes/proposal-reject.js +7 -0
  188. package/dist/output/shapes/proposal-show.js +7 -0
  189. package/dist/output/shapes/registry-search.js +6 -0
  190. package/dist/output/shapes/registry.js +30 -0
  191. package/dist/output/shapes/search.js +6 -0
  192. package/dist/output/shapes/secret-list.js +19 -0
  193. package/dist/output/shapes/show.js +6 -0
  194. package/dist/output/shapes/vault-list.js +19 -0
  195. package/dist/output/shapes.js +51 -511
  196. package/dist/output/text/add.js +6 -0
  197. package/dist/output/text/clone.js +6 -0
  198. package/dist/output/text/config.js +6 -0
  199. package/dist/output/text/curate.js +6 -0
  200. package/dist/output/text/distill.js +7 -0
  201. package/dist/output/text/enable-disable.js +7 -0
  202. package/dist/output/text/events.js +10 -0
  203. package/dist/output/text/feedback.js +6 -0
  204. package/dist/output/text/helpers.js +1039 -0
  205. package/dist/output/text/history.js +7 -0
  206. package/dist/output/text/import.js +6 -0
  207. package/dist/output/text/index.js +6 -0
  208. package/dist/output/text/info.js +6 -0
  209. package/dist/output/text/init.js +6 -0
  210. package/dist/output/text/list.js +6 -0
  211. package/dist/output/text/proposal-producer.js +8 -0
  212. package/dist/output/text/proposal.js +11 -0
  213. package/dist/output/text/registry-commands.js +11 -0
  214. package/dist/output/text/registry.js +30 -0
  215. package/dist/output/text/remember.js +6 -0
  216. package/dist/output/text/remove.js +6 -0
  217. package/dist/output/text/save.js +6 -0
  218. package/dist/output/text/search.js +6 -0
  219. package/dist/output/text/show.js +6 -0
  220. package/dist/output/text/update.js +6 -0
  221. package/dist/output/text/upgrade.js +6 -0
  222. package/dist/output/text/vault.js +16 -0
  223. package/dist/output/text/wiki.js +15 -0
  224. package/dist/output/text/workflow.js +14 -0
  225. package/dist/output/text.js +44 -1093
  226. package/dist/registry/build-index.js +3 -0
  227. package/dist/registry/create-provider-registry.js +3 -0
  228. package/dist/registry/factory.js +4 -1
  229. package/dist/registry/origin-resolve.js +3 -0
  230. package/dist/registry/providers/index.js +3 -0
  231. package/dist/registry/providers/skills-sh.js +71 -50
  232. package/dist/registry/providers/static-index.js +53 -48
  233. package/dist/registry/providers/types.js +3 -24
  234. package/dist/registry/resolve.js +11 -16
  235. package/dist/registry/types.js +3 -0
  236. package/dist/scripts/migrate-storage.js +17750 -0
  237. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9031 -0
  238. package/dist/scripts/migrations/v16-to-v17.js +141 -0
  239. package/dist/setup/detect.js +3 -0
  240. package/dist/setup/ripgrep-install.js +3 -0
  241. package/dist/setup/ripgrep-resolve.js +3 -0
  242. package/dist/setup/setup.js +775 -37
  243. package/dist/setup/steps.js +3 -15
  244. package/dist/sources/include.js +3 -0
  245. package/dist/sources/provider-factory.js +5 -12
  246. package/dist/sources/provider.js +3 -20
  247. package/dist/sources/providers/filesystem.js +19 -23
  248. package/dist/sources/providers/git.js +179 -20
  249. package/dist/sources/providers/index.js +3 -0
  250. package/dist/sources/providers/install-types.js +3 -13
  251. package/dist/sources/providers/npm.js +3 -4
  252. package/dist/sources/providers/provider-utils.js +3 -0
  253. package/dist/sources/providers/sync-from-ref.js +3 -11
  254. package/dist/sources/providers/tar-utils.js +3 -0
  255. package/dist/sources/providers/website.js +18 -22
  256. package/dist/sources/resolve.js +3 -0
  257. package/dist/sources/types.js +3 -0
  258. package/dist/sources/website-ingest.js +7 -0
  259. package/dist/tasks/backends/cron.js +203 -0
  260. package/dist/tasks/backends/exec-utils.js +28 -0
  261. package/dist/tasks/backends/index.js +24 -0
  262. package/dist/tasks/backends/launchd-template.xml +19 -0
  263. package/dist/tasks/backends/launchd.js +187 -0
  264. package/dist/tasks/backends/schtasks-template.xml +29 -0
  265. package/dist/tasks/backends/schtasks.js +215 -0
  266. package/dist/tasks/parser.js +211 -0
  267. package/dist/tasks/resolveAkmBin.js +87 -0
  268. package/dist/tasks/runner.js +458 -0
  269. package/dist/tasks/schedule.js +227 -0
  270. package/dist/tasks/schema.js +15 -0
  271. package/dist/tasks/validator.js +62 -0
  272. package/dist/version.js +3 -0
  273. package/dist/wiki/index-template.md +12 -0
  274. package/dist/wiki/ingest-workflow-template.md +54 -0
  275. package/dist/wiki/log-template.md +8 -0
  276. package/dist/wiki/schema-template.md +61 -0
  277. package/dist/wiki/wiki-templates.js +15 -0
  278. package/dist/wiki/wiki.js +13 -61
  279. package/dist/workflows/authoring.js +8 -25
  280. package/dist/workflows/cli.js +3 -0
  281. package/dist/workflows/db.js +141 -2
  282. package/dist/workflows/document-cache.js +3 -10
  283. package/dist/workflows/parser.js +3 -0
  284. package/dist/workflows/renderer.js +11 -3
  285. package/dist/workflows/runs.js +91 -89
  286. package/dist/workflows/schema.js +3 -0
  287. package/dist/workflows/scope-key.js +79 -0
  288. package/dist/workflows/validator.js +4 -8
  289. package/dist/workflows/workflow-template.md +24 -0
  290. package/docs/README.md +10 -2
  291. package/docs/data-and-telemetry.md +225 -0
  292. package/docs/migration/release-notes/0.7.0.md +1 -1
  293. package/docs/migration/release-notes/0.7.4.md +1 -1
  294. package/docs/migration/release-notes/0.7.5.md +20 -0
  295. package/docs/migration/release-notes/0.8.0.md +48 -0
  296. package/docs/migration/v0.7-to-v0.8.md +1307 -0
  297. package/package.json +29 -11
  298. package/dist/commands/install-audit.js +0 -381
  299. package/dist/commands/vault.js +0 -333
  300. package/dist/templates/wiki-templates.js +0 -100
@@ -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 { filterNonEmptyStrings } 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";
7
- import { getConfigDir as _getConfigDir, getConfigPath as _getConfigPath, getCacheDir } from "./paths";
10
+ export { stripJsonComments } from "./config-io";
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,31 +67,11 @@ export const DEFAULT_CONFIG = {
18
67
  detail: "brief",
19
68
  },
20
69
  };
21
- // ── Paths ───────────────────────────────────────────────────────────────────
22
- export function getConfigDir(env, platform) {
23
- return _getConfigDir(env, platform);
24
- }
25
- export function getConfigPath() {
26
- return _getConfigPath();
27
- }
28
70
  // ── Load / Save / Update ────────────────────────────────────────────────────
29
71
  const PROJECT_CONFIG_RELATIVE_PATH = path.join(".akm", "config.json");
30
72
  let cachedConfig;
31
- let cachedUserConfig;
32
73
  export function resetConfigCache() {
33
74
  cachedConfig = undefined;
34
- cachedUserConfig = undefined;
35
- }
36
- function hashString(text) {
37
- // Simple, fast non-cryptographic hash (FNV-1a 32-bit) — sufficient to detect
38
- // content changes between config writes when filesystem mtime resolution is
39
- // too coarse to reflect rapid back-to-back writes (common in tests).
40
- let hash = 0x811c9dc5;
41
- for (let i = 0; i < text.length; i++) {
42
- hash ^= text.charCodeAt(i);
43
- hash = Math.imul(hash, 0x01000193);
44
- }
45
- return (hash >>> 0).toString(16);
46
75
  }
47
76
  export function loadUserConfig() {
48
77
  const configPath = getConfigPath();
@@ -51,975 +80,362 @@ export function loadUserConfig() {
51
80
  stat = fs.statSync(configPath);
52
81
  }
53
82
  catch {
54
- cachedUserConfig = undefined;
83
+ cachedConfig = undefined;
55
84
  return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
56
85
  }
57
- // Cache key combines mtimeMs + size + content hash. mtimeMs alone is unreliable
58
- // when tests write multiple times within the filesystem mtime resolution
59
- // window (often 1ms+). Reading + hashing on cache miss is cheap and ensures
60
- // 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
+ }
61
95
  let text;
62
96
  try {
63
97
  text = fs.readFileSync(configPath, "utf8");
64
98
  }
65
99
  catch {
66
- cachedUserConfig = undefined;
100
+ cachedConfig = undefined;
67
101
  return applyRuntimeEnvApiKeys({ ...DEFAULT_CONFIG });
68
102
  }
69
- const contentHash = hashString(text);
70
- if (cachedUserConfig &&
71
- cachedUserConfig.path === configPath &&
72
- cachedUserConfig.mtime === stat.mtimeMs &&
73
- cachedUserConfig.size === stat.size &&
74
- cachedUserConfig.contentHash === contentHash) {
75
- 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.
76
115
  }
77
- const config = mergeLoadedConfig(DEFAULT_CONFIG, readNormalizedConfigFromText(text));
78
- const finalConfig = applyRuntimeEnvApiKeys(config);
79
- cachedUserConfig = {
116
+ cachedConfig = {
80
117
  config: finalConfig,
81
118
  path: configPath,
82
- mtime: stat.mtimeMs,
83
- size: stat.size,
84
- contentHash,
119
+ mtime: finalStat.mtimeMs,
120
+ size: finalStat.size,
85
121
  };
86
122
  return finalConfig;
87
123
  }
88
- export function loadConfig() {
89
- const configPaths = getEffectiveConfigPaths();
90
- const signature = getConfigSignature(configPaths);
91
- if (cachedConfig && cachedConfig.signature === signature) {
92
- return cachedConfig.config;
93
- }
94
- let config = loadUserConfig();
95
- const userConfigPath = getConfigPath();
96
- for (const configPath of configPaths) {
97
- if (configPath === userConfigPath)
98
- continue;
99
- config = mergeLoadedConfig(config, readNormalizedConfig(configPath));
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");
100
143
  }
101
- const finalConfig = applyRuntimeEnvApiKeys(config);
102
- cachedConfig = { config: finalConfig, signature };
103
- return finalConfig;
144
+ return mergeLoadedConfig(DEFAULT_CONFIG, parsed.data);
104
145
  }
105
- export function saveConfig(config) {
106
- cachedConfig = undefined;
107
- cachedUserConfig = undefined;
108
- const configPath = getConfigPath();
109
- const dir = path.dirname(configPath);
110
- fs.mkdirSync(dir, { recursive: true });
111
- backupExistingConfig(configPath);
112
- const sanitized = sanitizeConfigForWrite(config);
113
- writeConfigObject(configPath, sanitized);
146
+ export function getSources(config) {
147
+ return config.sources ?? [];
114
148
  }
115
- function backupExistingConfig(configPath) {
116
- if (!fs.existsSync(configPath))
117
- return;
118
- const backupDir = path.join(getCacheDir(), "config-backups");
119
- fs.mkdirSync(backupDir, { recursive: true });
120
- const timestamp = new Date().toISOString().replace(/[.:]/g, "-");
121
- const backupPath = path.join(backupDir, `config-${timestamp}.json`);
122
- fs.copyFileSync(configPath, backupPath);
123
- const latestPath = path.join(backupDir, "config.latest.json");
124
- fs.copyFileSync(configPath, latestPath);
149
+ export function getEffectiveRegistries(config) {
150
+ return config.registries ?? DEFAULT_CONFIG.registries ?? [];
125
151
  }
126
152
  /**
127
- * Strip apiKey fields before writing config to disk.
128
- * API keys should be provided via environment variables
129
- * 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.
130
167
  */
131
- function sanitizeConfigForWrite(config) {
132
- const sanitized = { ...config };
133
- if (config.embedding) {
134
- const { apiKey, ...rest } = config.embedding;
135
- sanitized.embedding = rest;
136
- }
137
- if (config.llm) {
138
- const { apiKey, ...rest } = config.llm;
139
- sanitized.llm = rest;
140
- }
141
- // Drop empty keys to keep config clean
142
- return sanitized;
143
- }
144
- export function updateConfig(partial) {
145
- const current = loadUserConfig();
146
- // Shallow-merge for top-level scalar fields; deep-merge known object-type config keys.
147
- const merged = { ...current, ...partial };
148
- // Deep-merge output — partial update should not wipe sibling keys
149
- if (current.output && partial.output && partial.output !== current.output) {
150
- merged.output = { ...current.output, ...partial.output };
151
- }
152
- // Deep-merge embedding — only when both sides are objects and partial does not intend to clear
153
- if (current.embedding && partial.embedding && partial.embedding !== current.embedding) {
154
- merged.embedding = { ...current.embedding, ...partial.embedding };
155
- }
156
- // Deep-merge llm — same pattern
157
- if (current.llm && partial.llm && partial.llm !== current.llm) {
158
- merged.llm = { ...current.llm, ...partial.llm };
159
- }
160
- // Deep-merge index per-pass entries so partial updates don't wipe siblings.
161
- if (current.index && partial.index && partial.index !== current.index) {
162
- const mergedIndex = { ...current.index };
163
- for (const [passName, passOverride] of Object.entries(partial.index)) {
164
- mergedIndex[passName] = { ...(mergedIndex[passName] ?? {}), ...passOverride };
165
- }
166
- merged.index = mergedIndex;
167
- }
168
- if (current.security && partial.security && partial.security !== current.security) {
169
- merged.security = mergeSecurityConfig(current.security, partial.security);
170
- }
171
- saveConfig(merged);
172
- 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;
173
175
  }
174
- // ── Helpers ─────────────────────────────────────────────────────────────────
175
176
  /**
176
- * Normalize a raw config object into a sparse config layer containing only
177
- * recognized keys that were valid in the source object. This function does not
178
- * merge with DEFAULT_CONFIG; callers are responsible for layering defaults and
179
- * combining multiple config sources so project config files only override what
180
- * 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).
181
184
  */
182
- function pickKnownKeys(raw) {
183
- const config = {};
184
- if (Array.isArray(raw.stashes)) {
185
- throw new ConfigError("The legacy `stashes[]` config key is no longer supported; rename it to `sources[]`.", "INVALID_CONFIG_FILE", `Edit ${_getConfigPath()} and replace \`stashes\` with \`sources\`.`);
186
- }
187
- if (typeof raw.stashDir === "string" && raw.stashDir.trim()) {
188
- config.stashDir = raw.stashDir.trim();
189
- }
190
- // Backward compatibility: coerce legacy boolean values to string
191
- if (typeof raw.semanticSearchMode === "boolean") {
192
- config.semanticSearchMode = raw.semanticSearchMode ? "auto" : "off";
193
- }
194
- else if (raw.semanticSearchMode === "off" || raw.semanticSearchMode === "auto") {
195
- config.semanticSearchMode = raw.semanticSearchMode;
196
- }
197
- const embedding = parseEmbeddingConfig(raw.embedding);
198
- if (embedding)
199
- config.embedding = embedding;
200
- const llm = parseLlmConfig(raw.llm);
201
- if (llm)
202
- config.llm = llm;
203
- const index = parseIndexConfig(raw.index);
204
- if (index)
205
- config.index = index;
206
- const installed = parseInstalledEntries(raw.installed);
207
- if (installed)
208
- config.installed = installed;
209
- const registries = parseRegistriesConfig(raw.registries);
210
- if (registries)
211
- config.registries = registries;
212
- if (raw.stashInheritance === "replace" || raw.stashInheritance === "merge") {
213
- config.stashInheritance = raw.stashInheritance;
214
- }
215
- const sources = parseSourcesConfig(raw.sources);
216
- if (sources) {
217
- config.sources = sources;
218
- }
219
- const security = parseSecurityConfig(raw.security);
220
- if (security)
221
- config.security = security;
222
- const output = parseOutputConfig(raw.output);
223
- if (output)
224
- config.output = output;
225
- if (typeof raw.writable === "boolean") {
226
- config.writable = raw.writable;
227
- }
228
- if (typeof raw.defaultWriteTarget === "string" && raw.defaultWriteTarget.trim()) {
229
- config.defaultWriteTarget = raw.defaultWriteTarget.trim();
230
- }
231
- if ("agent" in raw) {
232
- const agent = parseAgentConfig(raw.agent);
233
- if (agent)
234
- config.agent = agent;
235
- }
236
- if (typeof raw.search === "object" && raw.search !== null && !Array.isArray(raw.search)) {
237
- const searchRaw = raw.search;
238
- const searchConfig = {};
239
- if (typeof searchRaw.minScore === "number" && Number.isFinite(searchRaw.minScore) && searchRaw.minScore >= 0) {
240
- searchConfig.minScore = searchRaw.minScore;
241
- }
242
- if (Object.keys(searchConfig).length > 0)
243
- config.search = searchConfig;
244
- }
245
- return config;
246
- }
247
- function readNormalizedConfig(configPath) {
248
- const raw = readConfigObject(configPath);
249
- const expanded = raw ? expandEnvVars(raw) : undefined;
250
- return expanded ? pickKnownKeys(expanded) : undefined;
251
- }
252
- function readNormalizedConfigFromText(text) {
253
- const raw = parseConfigObjectFromText(text);
254
- if (!raw)
255
- return undefined;
256
- const expanded = expandEnvVars(raw);
257
- return pickKnownKeys(expanded);
258
- }
259
- function parseOutputConfig(value) {
260
- if (typeof value !== "object" || value === null || Array.isArray(value))
261
- return undefined;
262
- const obj = value;
263
- const output = {};
264
- if (obj.format === "json" || obj.format === "yaml" || obj.format === "text") {
265
- 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");
266
189
  }
267
- if (obj.detail === "brief" || obj.detail === "normal" || obj.detail === "full") {
268
- 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.`);
269
193
  }
270
- return Object.keys(output).length > 0 ? output : undefined;
194
+ return profile;
271
195
  }
272
196
  /**
273
- * Field names that hold URLs and must NOT have env var substitution applied.
274
- * Expanding ${VAR} inside a URL could leak secrets by redirecting requests to
275
- * 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.
276
199
  */
277
- 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
+ }
278
206
  /**
279
- * Recursively expand `${VAR}` references in all string values.
280
- * Supports `${VAR}`, `${VAR:-default}`, and bare `$VAR` at the start of a value.
281
- * 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`.
282
210
  *
283
- * URL-type fields (named `url`, `endpoint`, `artifactUrl`, or whose value starts
284
- * 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).
285
213
  */
286
- function expandEnvVars(value, fieldName) {
287
- if (typeof value === "string") {
288
- // Skip URL-type fields by name or by value prefix, unless they contain ${VAR} syntax
289
- if (!value.includes("${") &&
290
- ((fieldName !== undefined && URL_FIELD_NAMES.has(fieldName)) ||
291
- value.startsWith("http://") ||
292
- value.startsWith("https://"))) {
293
- return value;
294
- }
295
- return value.replace(/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, braced, bare) => {
296
- if (braced) {
297
- const [name, ...rest] = braced.split(":-");
298
- const fallback = rest.join(":-");
299
- return process.env[name] ?? fallback ?? "";
300
- }
301
- return process.env[bare] ?? "";
302
- });
303
- }
304
- if (Array.isArray(value)) {
305
- return value.map((item) => expandEnvVars(item));
306
- }
307
- if (value !== null && typeof value === "object") {
308
- const out = {};
309
- for (const [k, v] of Object.entries(value)) {
310
- out[k] = expandEnvVars(v, k);
311
- }
312
- return out;
313
- }
314
- return value;
315
- }
316
- function readConfigObject(configPath) {
214
+ function maybeAutoMigrateConfigFile(configPath, text) {
215
+ let obj;
317
216
  try {
318
- const text = fs.readFileSync(configPath, "utf8");
319
- return parseConfigObjectFromText(text);
217
+ obj = parseConfigText(text);
320
218
  }
321
219
  catch {
322
- return undefined;
220
+ return text; // Malformed JSON — let parseAndValidate surface the error.
323
221
  }
324
- }
325
- function parseConfigObjectFromText(text) {
326
- try {
327
- const raw = JSON.parse(stripJsonComments(text));
328
- if (typeof raw !== "object" || raw === null || Array.isArray(raw))
329
- return undefined;
330
- return raw;
331
- }
332
- catch {
333
- return undefined;
222
+ if (compareConfigVersion(obj.configVersion, CURRENT_CONFIG_VERSION) === 1) {
223
+ return text;
334
224
  }
335
- }
336
- function writeConfigObject(configPath, config) {
337
- const tmpPath = `${configPath}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
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;
338
231
  try {
339
- fs.writeFileSync(tmpPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
340
- fs.renameSync(tmpPath, configPath);
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`);
341
252
  }
342
253
  catch (err) {
343
- try {
344
- fs.unlinkSync(tmpPath);
345
- }
346
- catch {
347
- /* ignore cleanup failure */
348
- }
349
- throw 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.");
350
258
  }
259
+ return migratedText;
351
260
  }
352
- /**
353
- * Strip JavaScript-style comments from a JSON string (JSONC support).
354
- * Handles // line comments and /* block comments while preserving
355
- * comment-like sequences inside quoted strings.
356
- */
357
- export function stripJsonComments(text) {
358
- let result = "";
359
- let i = 0;
360
- let inString = false;
361
- while (i < text.length) {
362
- if (inString) {
363
- if (text[i] === "\\") {
364
- result += text[i] + (text[i + 1] ?? "");
365
- i += 2;
366
- continue;
367
- }
368
- if (text[i] === '"') {
369
- inString = false;
370
- }
371
- result += text[i];
372
- i++;
373
- continue;
374
- }
375
- // JSON only uses double-quoted strings; single quotes are not valid JSON
376
- if (text[i] === '"') {
377
- inString = true;
378
- result += text[i];
379
- i++;
380
- continue;
381
- }
382
- if (text[i] === "/" && text[i + 1] === "/") {
383
- while (i < text.length && text[i] !== "\n")
384
- i++;
385
- continue;
386
- }
387
- if (text[i] === "/" && text[i + 1] === "*") {
388
- i += 2;
389
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
390
- i++;
391
- i += 2;
392
- continue;
393
- }
394
- result += text[i];
395
- i++;
396
- }
397
- return result;
398
- }
399
- function parseEmbeddingConfig(value) {
400
- if (typeof value !== "object" || value === null || Array.isArray(value))
401
- return undefined;
402
- const obj = value;
403
- // Extract localModel early — it's valid even without a remote endpoint
404
- const localModel = typeof obj.localModel === "string" && obj.localModel ? obj.localModel : undefined;
405
- // If no endpoint is provided, the config is only valid when localModel is set
406
- // (local-only embedding configuration).
407
- // Sentinel: { endpoint: "", model: "" } means "local-only" — use hasRemoteEndpoint()
408
- // (in embedder.ts) to distinguish from a real remote config. Do NOT check
409
- // endpoint/model directly in consuming code.
410
- if (typeof obj.endpoint !== "string" || !obj.endpoint) {
411
- if (localModel) {
412
- return { endpoint: "", model: "", localModel };
413
- }
414
- return undefined;
415
- }
416
- if (!obj.endpoint.startsWith("http://") && !obj.endpoint.startsWith("https://")) {
417
- warn(`[akm] Ignoring embedding config: endpoint must start with http:// or https://, got "${obj.endpoint}"`);
418
- // Still return localModel-only config if localModel was set
419
- if (localModel) {
420
- return { endpoint: "", model: "", localModel };
421
- }
422
- return undefined;
423
- }
424
- if (typeof obj.model !== "string" || !obj.model) {
425
- // No remote model, but localModel may still be valid
426
- if (localModel) {
427
- warn(`[akm] Embedding endpoint "${obj.endpoint}" ignored: model is required for remote embeddings. Using local model only.`);
428
- return { endpoint: "", model: "", localModel };
429
- }
430
- return undefined;
431
- }
432
- const result = {
433
- endpoint: obj.endpoint,
434
- model: obj.model,
435
- };
436
- if (typeof obj.provider === "string" && obj.provider) {
437
- result.provider = obj.provider;
438
- }
439
- if ("dimension" in obj) {
440
- if (typeof obj.dimension !== "number" ||
441
- !Number.isFinite(obj.dimension) ||
442
- !Number.isInteger(obj.dimension) ||
443
- obj.dimension <= 0) {
444
- return undefined;
445
- }
446
- result.dimension = obj.dimension;
447
- }
448
- if (typeof obj.apiKey === "string" && obj.apiKey) {
449
- result.apiKey = obj.apiKey;
450
- }
451
- if (localModel) {
452
- result.localModel = localModel;
453
- }
454
- if ("contextLength" in obj) {
455
- if (typeof obj.contextLength !== "number" ||
456
- !Number.isFinite(obj.contextLength) ||
457
- !Number.isInteger(obj.contextLength) ||
458
- obj.contextLength <= 0) {
459
- return undefined;
460
- }
461
- result.contextLength = obj.contextLength;
462
- }
463
- if (typeof obj.ollamaOptions === "object" && obj.ollamaOptions !== null && !Array.isArray(obj.ollamaOptions)) {
464
- const opts = obj.ollamaOptions;
465
- const parsed = {};
466
- if (typeof opts.num_ctx === "number" &&
467
- Number.isFinite(opts.num_ctx) &&
468
- Number.isInteger(opts.num_ctx) &&
469
- opts.num_ctx > 0) {
470
- parsed.num_ctx = opts.num_ctx;
471
- }
472
- if (Object.keys(parsed).length > 0) {
473
- result.ollamaOptions = parsed;
474
- }
475
- }
476
- 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();
477
267
  }
478
- function parseLlmConfig(value) {
479
- if (typeof value !== "object" || value === null || Array.isArray(value))
480
- return undefined;
481
- const obj = value;
482
- if (typeof obj.endpoint !== "string" || !obj.endpoint)
483
- return undefined;
484
- if (!obj.endpoint.startsWith("http://") && !obj.endpoint.startsWith("https://")) {
485
- warn(`[akm] Ignoring llm config: endpoint must start with http:// or https://, got "${obj.endpoint}"`);
486
- return undefined;
487
- }
488
- const model = typeof obj.model === "string" ? obj.model : "";
489
- const result = {
490
- endpoint: obj.endpoint,
491
- model,
492
- };
493
- if (typeof obj.provider === "string" && obj.provider) {
494
- result.provider = obj.provider;
495
- }
496
- if (typeof obj.temperature === "number" && Number.isFinite(obj.temperature)) {
497
- result.temperature = obj.temperature;
498
- }
499
- if ("maxTokens" in obj) {
500
- if (typeof obj.maxTokens !== "number" ||
501
- !Number.isFinite(obj.maxTokens) ||
502
- !Number.isInteger(obj.maxTokens) ||
503
- obj.maxTokens <= 0) {
504
- return undefined;
505
- }
506
- result.maxTokens = obj.maxTokens;
507
- }
508
- if (typeof obj.apiKey === "string" && obj.apiKey) {
509
- result.apiKey = obj.apiKey;
510
- }
511
- if (typeof obj.capabilities === "object" && obj.capabilities !== null && !Array.isArray(obj.capabilities)) {
512
- const capsRaw = obj.capabilities;
513
- const caps = {};
514
- if (typeof capsRaw.structuredOutput === "boolean")
515
- caps.structuredOutput = capsRaw.structuredOutput;
516
- if (Object.keys(caps).length > 0)
517
- result.capabilities = caps;
518
- }
519
- if (typeof obj.features === "object" && obj.features !== null && !Array.isArray(obj.features)) {
520
- const features = parseLlmFeatures(obj.features);
521
- if (Object.keys(features).length > 0)
522
- result.features = features;
523
- }
524
- if (typeof obj.extraParams === "object" && obj.extraParams !== null && !Array.isArray(obj.extraParams)) {
525
- result.extraParams = obj.extraParams;
526
- }
527
- 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
+ });
528
289
  }
529
290
  /**
530
- * v1 spec §14 locked feature keys. Defined here so unknown keys can
531
- * be warn-and-ignored at load time (per spec §14.3 / §9.2). The set is
532
- * deliberately the *full* locked table even though only a subset has
533
- * runtime parsing today; this lets users author future-flagged configs
534
- * 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.
535
304
  */
536
- const LOCKED_LLM_FEATURE_KEYS = new Set([
537
- "curate_rerank",
538
- "feedback_distillation",
539
- "memory_inference",
540
- "graph_extraction",
541
- ]);
542
- function parseLlmFeatures(raw) {
543
- const out = {};
544
- for (const [key, value] of Object.entries(raw)) {
545
- if (!LOCKED_LLM_FEATURE_KEYS.has(key)) {
546
- warn(`[akm] Ignoring unknown llm.features key "${key}".`);
547
- continue;
548
- }
549
- if (typeof value !== "boolean") {
550
- warn(`[akm] Ignoring llm.features.${key}: expected boolean, got ${typeof value}.`);
551
- 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 };
552
313
  }
553
- switch (key) {
554
- case "memory_inference":
555
- out.memory_inference = value;
556
- break;
557
- case "graph_extraction":
558
- out.graph_extraction = value;
559
- break;
560
- case "curate_rerank":
561
- out.curate_rerank = value;
562
- break;
563
- case "feedback_distillation":
564
- out.feedback_distillation = value;
565
- break;
566
- // No default: LOCKED_LLM_FEATURE_KEYS is the source of truth for which
567
- // keys are accepted. Adding a new locked key requires an arm here AND a
568
- // field on LlmFeatureFlags above.
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)");
569
319
  }
570
320
  }
571
- return out;
572
- }
573
- /**
574
- * Keys that, if present anywhere under `index.<pass>`, indicate the user is
575
- * trying to supply a parallel LLM provider configuration. Per #208 this is
576
- * deliberately rejected at load time so there is exactly one place to
577
- * configure the LLM (`akm.llm`).
578
- */
579
- const PROVIDER_CONFIG_KEYS = new Set([
580
- "endpoint",
581
- "model",
582
- "provider",
583
- "apiKey",
584
- "baseUrl",
585
- "temperature",
586
- "maxTokens",
587
- "capabilities",
588
- ]);
589
- /**
590
- * Parse the `index` config block. Each entry is a pass name → small object
591
- * `{ llm?: boolean }`. Anything richer (a parallel provider config, unknown
592
- * keys, non-boolean `llm`) throws `ConfigError("INVALID_CONFIG_FILE")` at
593
- * load time so the failure is visible at startup, not on the next index run.
594
- */
595
- function parseIndexConfig(value) {
596
- if (value === undefined || value === null)
597
- return undefined;
598
- if (typeof value !== "object" || Array.isArray(value)) {
599
- 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 };
600
323
  }
601
- const out = {};
602
- for (const [passName, raw] of Object.entries(value)) {
603
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
604
- throw new ConfigError(`Invalid \`index.${passName}\` config: expected an object like \`{ "llm": false }\`.`, "INVALID_CONFIG_FILE");
605
- }
606
- const passRaw = raw;
607
- // Reject any provider-shaped key — there must be exactly one place to
608
- // configure the LLM (#208). This is the duplicate-provider guard.
609
- for (const key of Object.keys(passRaw)) {
610
- if (PROVIDER_CONFIG_KEYS.has(key)) {
611
- throw new ConfigError(`Duplicate LLM provider configuration: \`index.${passName}.${key}\` is not allowed. ` +
612
- "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.');
613
- }
614
- if (key !== "llm") {
615
- throw new ConfigError(`Unknown key \`index.${passName}.${key}\`. Per-pass entries only support \`llm\` (boolean opt-out).`, "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
+ }
616
339
  }
617
- }
618
- const passConfig = {};
619
- if ("llm" in passRaw) {
620
- const llmFlag = passRaw.llm;
621
- if (typeof llmFlag !== "boolean") {
622
- 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.");
340
+ else {
341
+ llmProfiles[name] = { ...profile };
623
342
  }
624
- passConfig.llm = llmFlag;
625
343
  }
626
- out[passName] = passConfig;
344
+ sanitized.profiles = {
345
+ ...(sanitized.profiles ?? {}),
346
+ llm: llmProfiles,
347
+ };
627
348
  }
628
- return out;
629
- }
630
- function parseInstalledEntries(value) {
631
- if (!Array.isArray(value))
632
- return undefined;
633
- const entries = value
634
- .map((entry) => parseInstalledStashEntry(entry))
635
- .filter((entry) => entry !== undefined);
636
- return entries.length > 0 ? entries : undefined;
637
- }
638
- function parseInstalledStashEntry(value) {
639
- if (typeof value !== "object" || value === null || Array.isArray(value))
640
- return undefined;
641
- const obj = value;
642
- const id = asNonEmptyString(obj.id);
643
- const source = asKitSource(obj.source);
644
- const ref = asNonEmptyString(obj.ref);
645
- const artifactUrl = asNonEmptyString(obj.artifactUrl);
646
- const stashRoot = asNonEmptyString(obj.stashRoot);
647
- const cacheDir = asNonEmptyString(obj.cacheDir);
648
- const installedAt = asNonEmptyString(obj.installedAt);
649
- if (!id || !source || !ref || !artifactUrl || !stashRoot || !cacheDir || !installedAt)
650
- return undefined;
651
- const entry = {
652
- id,
653
- source,
654
- ref,
655
- artifactUrl,
656
- stashRoot,
657
- cacheDir,
658
- installedAt,
659
- };
660
- if (typeof obj.writable === "boolean")
661
- entry.writable = obj.writable;
662
- if (entry.writable === true && entry.source !== "git") {
663
- 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.");
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.`);
664
351
  }
665
- const resolvedVersion = asNonEmptyString(obj.resolvedVersion);
666
- if (resolvedVersion)
667
- entry.resolvedVersion = resolvedVersion;
668
- const resolvedRevision = asNonEmptyString(obj.resolvedRevision);
669
- if (resolvedRevision)
670
- entry.resolvedRevision = resolvedRevision;
671
- const wikiName = asNonEmptyString(obj.wikiName);
672
- if (wikiName)
673
- entry.wikiName = wikiName;
674
- return entry;
352
+ return sanitized;
675
353
  }
676
- function asNonEmptyString(value) {
677
- return typeof value === "string" && value ? value : undefined;
354
+ /** Matches `${VAR}`, `${VAR:-default}`, or `$VAR`. */
355
+ function isEnvReference(value) {
356
+ return /^\$\{[^}]+\}$|^\$[A-Za-z_][A-Za-z0-9_]*$/.test(value);
357
+ }
358
+ export function updateConfig(partial) {
359
+ const current = loadUserConfig();
360
+ const merged = mergeLoadedConfig(current, partial);
361
+ saveConfig(merged);
362
+ return merged;
678
363
  }
364
+ // ── Helpers ─────────────────────────────────────────────────────────────────
679
365
  /**
680
- * 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.
681
370
  *
682
- * Restricted to the four kinds that the install pipeline produces
683
- * (`"npm" | "github" | "git" | "local"`). The full {@link KitSource} union is
684
- * wider, but persisted `installed[]` entries should never carry the runtime
685
- * 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.
686
377
  */
687
- function asKitSource(value) {
688
- if (value === "npm" || value === "github" || value === "git" || value === "local")
689
- return value;
690
- return undefined;
691
- }
692
- function parseRegistriesConfig(value) {
693
- if (!Array.isArray(value))
694
- return undefined;
695
- const entries = value
696
- .map((entry) => parseRegistryConfigEntry(entry))
697
- .filter((entry) => entry !== undefined);
698
- // Return the array even if empty — an explicit empty array means "no registries"
699
- // which overrides the default. Only return undefined if the field was not an array.
700
- return entries;
701
- }
702
- function parseSourcesConfig(value) {
703
- if (!Array.isArray(value))
378
+ export function resolveSecret(value) {
379
+ if (value === undefined)
704
380
  return undefined;
705
- const entries = value
706
- .map((entry) => parseSourceConfigEntry(entry))
707
- .filter((entry) => entry !== undefined);
708
- return entries;
709
- }
710
- function parseSecurityConfig(value) {
711
- if (typeof value !== "object" || value === null || Array.isArray(value))
712
- return undefined;
713
- const obj = value;
714
- const installAudit = parseInstallAuditConfig(obj.installAudit);
715
- if (!installAudit)
716
- return undefined;
717
- return { installAudit };
718
- }
719
- function parseInstallAuditConfig(value) {
720
- if (typeof value !== "object" || value === null || Array.isArray(value))
721
- return undefined;
722
- const obj = value;
723
- const config = {};
724
- if (typeof obj.enabled === "boolean")
725
- config.enabled = obj.enabled;
726
- if (typeof obj.blockOnCritical === "boolean")
727
- config.blockOnCritical = obj.blockOnCritical;
728
- if (typeof obj.blockUnlistedRegistries === "boolean")
729
- config.blockUnlistedRegistries = obj.blockUnlistedRegistries;
730
- const rawAllowlist = filterNonEmptyStrings(obj.registryAllowlist) ?? filterNonEmptyStrings(obj.registryWhitelist);
731
- if (rawAllowlist) {
732
- config.registryAllowlist = rawAllowlist;
733
- }
734
- const allowedFindings = parseInstallAuditAllowedFindings(obj.allowedFindings);
735
- if (allowedFindings) {
736
- config.allowedFindings = allowedFindings;
737
- }
738
- return Object.keys(config).length > 0 ? config : undefined;
739
- }
740
- function parseInstallAuditAllowedFindings(value) {
741
- if (!Array.isArray(value))
742
- return undefined;
743
- const findings = value
744
- .map((entry) => parseInstallAuditAllowedFinding(entry))
745
- .filter((entry) => entry !== undefined);
746
- return findings.length > 0 ? findings : undefined;
747
- }
748
- function parseInstallAuditAllowedFinding(value) {
749
- if (typeof value !== "object" || value === null || Array.isArray(value))
750
- return undefined;
751
- const obj = value;
752
- const id = asNonEmptyString(obj.id);
753
- if (!id)
754
- return undefined;
755
- const finding = { id };
756
- const ref = asNonEmptyString(obj.ref);
757
- if (ref)
758
- finding.ref = ref;
759
- const entryPath = asNonEmptyString(obj.path);
760
- if (entryPath)
761
- finding.path = entryPath;
762
- const reason = asNonEmptyString(obj.reason);
763
- if (reason)
764
- finding.reason = reason;
765
- return finding;
766
- }
767
- function parseSourceConfigEntry(value) {
768
- if (typeof value !== "object" || value === null || Array.isArray(value))
769
- return undefined;
770
- const obj = value;
771
- const type = asNonEmptyString(obj.type);
772
- if (!type)
773
- return undefined;
774
- if (type === "openviking") {
775
- const name = asNonEmptyString(obj.name) ?? "unnamed";
776
- 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.`);
777
- }
778
- const entry = { type };
779
- const entryPath = asNonEmptyString(obj.path);
780
- if (entryPath)
781
- entry.path = entryPath;
782
- const url = asNonEmptyString(obj.url);
783
- if (url)
784
- entry.url = url;
785
- const name = asNonEmptyString(obj.name);
786
- if (name)
787
- entry.name = name;
788
- if (typeof obj.enabled === "boolean")
789
- entry.enabled = obj.enabled;
790
- if (typeof obj.writable === "boolean")
791
- entry.writable = obj.writable;
792
- if (typeof obj.primary === "boolean")
793
- entry.primary = obj.primary;
794
- // Locked decision 4 (§6 v1 implementation plan): reject writable: true on
795
- // website / npm sources at config load. The next sync() would clobber
796
- // writes — allowing this is a footgun, not a feature. Throw early so the
797
- // user sees the problem at `akm` startup, not when they try to write.
798
- if (entry.writable === true && (type === "website" || type === "npm")) {
799
- const label = entry.name ? ` "${entry.name}"` : "";
800
- 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.");
801
- }
802
- if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
803
- entry.options = obj.options;
804
- }
805
- const wikiName = asNonEmptyString(obj.wikiName);
806
- if (wikiName)
807
- entry.wikiName = wikiName;
808
- return entry;
809
- }
810
- // ── ConfiguredSource runtime construction ─────────────────────────────────────────
811
- /**
812
- * Synthesize a stable identifier when a {@link SourceConfigEntry} omits its
813
- * `name`. Uses a short hash of the discriminating fields so two equivalent
814
- * entries collapse to the same generated name.
815
- */
816
- function deriveStashEntryName(entry) {
817
- if (entry.name)
818
- return entry.name;
819
- const seed = JSON.stringify({
820
- type: entry.type,
821
- path: entry.path ?? null,
822
- 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] ?? "";
823
392
  });
824
- const hash = createHash("sha256").update(seed).digest("hex").slice(0, 8);
825
- return `${entry.type}-${hash}`;
826
393
  }
827
394
  /**
828
- * Convert a persisted {@link SourceConfigEntry} into the runtime
829
- * {@link SourceSpec} discriminated union. Returns `undefined` when the
830
- * entry is missing the fields its provider type requires (e.g. a
831
- * `filesystem` entry with no `path`); callers should drop or warn for those.
832
- *
833
- * Unknown provider types fall back to `{ type: "filesystem", path: ... }` when
834
- * a `path` is supplied, so future provider types still produce a usable
835
- * runtime value.
836
- */
837
- export function parseSourceSpec(entry) {
838
- switch (entry.type) {
839
- case "filesystem":
840
- return entry.path ? { type: "filesystem", path: entry.path } : undefined;
841
- case "git":
842
- return entry.url ? { type: "git", url: entry.url } : undefined;
843
- case "website":
844
- return entry.url
845
- ? {
846
- type: "website",
847
- url: entry.url,
848
- ...(typeof entry.options?.maxPages === "number" ? { maxPages: entry.options.maxPages } : {}),
849
- }
850
- : undefined;
851
- case "npm":
852
- // Persisted `npm` stash entries are unusual but supported for symmetry.
853
- return entry.path ? { type: "npm", package: entry.path } : undefined;
854
- default:
855
- // Unknown provider — best-effort fallback so callers still get something.
856
- return entry.path ? { type: "filesystem", path: entry.path } : undefined;
857
- }
858
- }
859
- /**
860
- * Build the full ordered list of runtime {@link ConfiguredSource} values from a
861
- * loaded {@link AkmConfig}. Order is the canonical iteration order:
862
- *
863
- * 1. The entry marked `primary: true` (or, as a backwards-compat shim,
864
- * a synthetic filesystem entry built from the top-level `stashDir`).
865
- * 2. Remaining `sources[]` entries in declared order.
866
- * 3. Legacy `installed[]` entries, mapped into runtime entries.
867
- *
868
- * Entries with `enabled: false` are still emitted — callers decide whether
869
- * to honour the flag (mirrors how `installed[]` entries have always been
870
- * unconditional). Entries that fail {@link parseSourceSpec} are
871
- * dropped silently.
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.
872
398
  */
873
- export function resolveConfiguredSources(config) {
874
- const entries = [];
875
- const sources = config.sources ?? [];
876
- // (1) Primary entry: explicit `primary: true` wins; fall back to top-level stashDir.
877
- let primary = sources.find((entry) => entry.primary === true);
878
- if (!primary && config.stashDir) {
879
- primary = { type: "filesystem", path: config.stashDir, primary: true };
880
- }
881
- if (primary) {
882
- const runtime = toConfiguredSource(primary, true);
883
- if (runtime)
884
- entries.push(runtime);
885
- }
886
- // (2) Declared sources (skip the primary entry — already added).
887
- for (const entry of sources) {
888
- if (entry === primary)
889
- continue;
890
- const runtime = toConfiguredSource(entry, false);
891
- if (runtime)
892
- entries.push(runtime);
893
- }
894
- // (3) Legacy installed[] entries.
895
- for (const installed of config.installed ?? []) {
896
- entries.push({
897
- name: installed.id,
898
- type: "filesystem",
899
- source: { type: "filesystem", path: installed.stashRoot },
900
- enabled: true,
901
- writable: installed.writable,
902
- ...(installed.wikiName ? { wikiName: installed.wikiName } : {}),
903
- });
904
- }
905
- return entries;
906
- }
907
- function toConfiguredSource(persisted, isPrimary) {
908
- const source = parseSourceSpec(persisted);
909
- 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)
910
403
  return undefined;
911
- return {
912
- name: deriveStashEntryName(persisted),
913
- type: persisted.type,
914
- source,
915
- ...(persisted.enabled !== undefined ? { enabled: persisted.enabled } : {}),
916
- ...(persisted.writable !== undefined ? { writable: persisted.writable } : {}),
917
- ...(isPrimary || persisted.primary ? { primary: true } : {}),
918
- ...(persisted.options ? { options: persisted.options } : {}),
919
- ...(persisted.wikiName ? { wikiName: persisted.wikiName } : {}),
920
- };
921
- }
922
- function parseRegistryConfigEntry(value) {
923
- if (typeof value !== "object" || value === null || Array.isArray(value))
404
+ if (INDEX_RESERVED_KEYS.has(passName))
924
405
  return undefined;
925
- const obj = value;
926
- const url = asNonEmptyString(obj.url);
927
- if (!url?.startsWith("http"))
406
+ const entry = config[passName];
407
+ if (!entry || typeof entry !== "object")
928
408
  return undefined;
929
- const entry = { url };
930
- const name = asNonEmptyString(obj.name);
931
- if (name)
932
- entry.name = name;
933
- if (typeof obj.enabled === "boolean")
934
- entry.enabled = obj.enabled;
935
- const provider = asNonEmptyString(obj.provider);
936
- if (provider)
937
- entry.provider = provider;
938
- if (typeof obj.options === "object" && obj.options !== null && !Array.isArray(obj.options)) {
939
- entry.options = obj.options;
940
- }
941
409
  return entry;
942
410
  }
943
- function mergeAgentConfig(base, override) {
944
- const merged = { ...base, ...override };
945
- const baseProfiles = base.profiles;
946
- const overrideProfiles = override.profiles;
947
- if (baseProfiles && overrideProfiles) {
948
- const profiles = { ...baseProfiles };
949
- for (const [name, entry] of Object.entries(overrideProfiles)) {
950
- const existing = baseProfiles[name];
951
- profiles[name] = existing ? { ...existing, ...entry } : entry;
952
- }
953
- merged.profiles = profiles;
954
- }
955
- return merged;
956
- }
957
- function mergeSecurityConfig(base, override) {
958
- if (!base && !override)
959
- return undefined;
960
- const installAudit = mergeInstallAuditConfig(base?.installAudit, override?.installAudit);
961
- return installAudit ? { installAudit } : undefined;
962
- }
963
- function mergeInstallAuditConfig(base, override) {
964
- if (!base && !override)
965
- return undefined;
966
- const merged = {
967
- ...(base ?? {}),
968
- ...(override ?? {}),
969
- };
970
- return Object.values(merged).some((value) => value !== undefined) ? merged : undefined;
971
- }
411
+ // Re-export source runtime helpers — implementation lives in config-sources.ts.
412
+ export { parseSourceSpec, resolveConfiguredSources } from "./config-sources";
972
413
  /**
973
- * Merge a normalized config layer into an accumulated config.
974
- *
975
- * Scalar fields follow normal override semantics. Known nested objects are
976
- * deep-merged so project config files can override individual fields without
977
- * clobbering sibling settings. `sources` are additive by default, but a later
978
- * 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.
979
418
  */
980
419
  function mergeLoadedConfig(base, override) {
981
420
  if (!override)
982
421
  return { ...base };
983
- const merged = {
984
- ...base,
985
- ...override,
986
- };
987
- if (base.output && override.output) {
988
- merged.output = { ...base.output, ...override.output };
989
- }
990
- if (base.embedding && override.embedding) {
991
- merged.embedding = { ...base.embedding, ...override.embedding };
992
- }
993
- if (base.llm && override.llm) {
994
- merged.llm = { ...base.llm, ...override.llm };
995
- }
996
- if (base.index || override.index) {
997
- // Deep-merge per-pass entries so a project layer can opt one pass out
998
- // without dropping siblings configured in user config.
999
- const mergedIndex = { ...(base.index ?? {}) };
1000
- for (const [passName, passOverride] of Object.entries(override.index ?? {})) {
1001
- 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] };
1002
429
  }
1003
- if (Object.keys(mergedIndex).length > 0)
1004
- merged.index = mergedIndex;
1005
- }
1006
- if (base.security && override.security) {
1007
- merged.security = mergeSecurityConfig(base.security, override.security);
1008
- }
1009
- if (base.agent && override.agent) {
1010
- merged.agent = mergeAgentConfig(base.agent, override.agent);
1011
- }
1012
- const replaceSources = override.stashInheritance === "replace";
1013
- const overrideSources = override.sources ?? [];
1014
- const baseSources = base.sources ?? [];
1015
- if (replaceSources) {
1016
- merged.sources = [...overrideSources];
1017
430
  }
1018
- else if (overrideSources.length > 0) {
1019
- merged.sources = [...baseSources, ...overrideSources];
1020
- }
1021
- else if (baseSources.length > 0) {
1022
- 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;
1023
439
  }
1024
440
  return merged;
1025
441
  }
@@ -1030,51 +446,51 @@ function applyRuntimeEnvApiKeys(config) {
1030
446
  if (envKey)
1031
447
  next.embedding = { ...next.embedding, apiKey: envKey };
1032
448
  }
1033
- if (next.llm && !next.llm.apiKey) {
1034
- const envKey = process.env.AKM_LLM_API_KEY?.trim();
1035
- if (envKey)
1036
- 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 };
1037
468
  }
1038
469
  return next;
1039
470
  }
1040
471
  /**
1041
- * Return config file paths in merge order: user config first, then project
1042
- * config files from the outermost parent directory down to the current working
1043
- * directory. Later entries have higher precedence when merged.
1044
- */
1045
- function getEffectiveConfigPaths() {
1046
- const configPath = getConfigPath();
1047
- const paths = [];
1048
- if (isFile(configPath)) {
1049
- paths.push(configPath);
1050
- }
1051
- return [...paths, ...discoverProjectConfigPaths(process.cwd())];
1052
- }
1053
- /**
1054
- * Walk from `startDir` up to the filesystem root and collect `.akm/config.json`
1055
- * files. Paths are returned from outermost parent to innermost directory so
1056
- * 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.
1057
477
  */
1058
- function discoverProjectConfigPaths(startDir) {
1059
- const paths = [];
478
+ const PROJECT_CONFIG_DEPRECATION_WARNED = new Set();
479
+ function warnIfProjectConfigPresent(startDir) {
1060
480
  let currentDir = path.resolve(startDir);
1061
481
  while (true) {
1062
482
  const configPath = path.join(currentDir, PROJECT_CONFIG_RELATIVE_PATH);
1063
- if (isFile(configPath)) {
1064
- 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.");
1065
488
  }
1066
489
  const parentDir = path.dirname(currentDir);
1067
- if (parentDir === currentDir) {
490
+ if (parentDir === currentDir)
1068
491
  break;
1069
- }
1070
492
  currentDir = parentDir;
1071
493
  }
1072
- return paths;
1073
- }
1074
- function getConfigSignature(configPaths) {
1075
- if (configPaths.length === 0)
1076
- return "defaults";
1077
- return configPaths.map((configPath) => `${configPath}:${getFileSignatureToken(configPath)}`).join("|");
1078
494
  }
1079
495
  function isFile(filePath) {
1080
496
  try {
@@ -1084,23 +500,3 @@ function isFile(filePath) {
1084
500
  return false;
1085
501
  }
1086
502
  }
1087
- function getFileSignatureToken(filePath) {
1088
- try {
1089
- const stat = fs.statSync(filePath);
1090
- // mtimeMs alone is unreliable on filesystems with low-resolution mtime
1091
- // (HFS+, some network FS, or very fast back-to-back writes in tests).
1092
- // Combine mtime + size + content hash so the signature actually changes
1093
- // when content does.
1094
- let contentHash = "";
1095
- try {
1096
- contentHash = hashString(fs.readFileSync(filePath, "utf8"));
1097
- }
1098
- catch {
1099
- // ignore — fall back to stat-only signature
1100
- }
1101
- return `${stat.mtimeMs}:${stat.size}:${contentHash}`;
1102
- }
1103
- catch {
1104
- return "missing";
1105
- }
1106
- }